@arcote.tech/arc-ds 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-ds",
3
3
  "type": "module",
4
- "version": "0.4.7",
4
+ "version": "0.4.9",
5
5
  "private": false,
6
6
  "author": "Przemysław Krasiński [arcote.tech]",
7
7
  "description": "Design System for Arc framework — CVA-based components with display modes and variant overrides",
@@ -28,7 +28,7 @@
28
28
  "tailwind-merge": "^3.5.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@arcote.tech/arc": "^0.4.7",
31
+ "@arcote.tech/arc": "^0.4.9",
32
32
  "framer-motion": "^12.0.0",
33
33
  "lucide-react": ">=0.400.0",
34
34
  "radix-ui": "^1.0.0",
@@ -0,0 +1,87 @@
1
+ import { Box } from "../box/box";
2
+ import { Button } from "../button/button";
3
+ import { TextareaField } from "../form";
4
+ import { SearchSelect } from "../search-select/search-select";
5
+ import { Send, Globe } from "lucide-react";
6
+ import { useState, type ReactNode } from "react";
7
+ import type { ChatModel, SendMessageOptions } from "./types";
8
+
9
+ interface ChatInputProps {
10
+ onSend: (message: string, options: SendMessageOptions) => void;
11
+ models: ChatModel[];
12
+ defaultModel?: string;
13
+ /** Extra toolbar content (e.g., attach menu) rendered after web search toggle */
14
+ toolbar?: ReactNode;
15
+ /** Label for web search toggle */
16
+ webSearchLabel?: string;
17
+ /** Placeholder for message input */
18
+ placeholder?: string;
19
+ }
20
+
21
+ export function ChatInput({
22
+ onSend,
23
+ models,
24
+ defaultModel,
25
+ toolbar,
26
+ webSearchLabel = "Web",
27
+ placeholder = "Type a message...",
28
+ }: ChatInputProps) {
29
+ const [message, setMessage] = useState("");
30
+ const [model, setModel] = useState(defaultModel ?? models[0]?.value ?? "");
31
+ const [webSearch, setWebSearch] = useState(false);
32
+
33
+ const handleSend = () => {
34
+ const text = message.trim();
35
+ if (!text) return;
36
+ onSend(text, { model, webSearch });
37
+ setMessage("");
38
+ };
39
+
40
+ return (
41
+ <Box className="p-3 space-y-2">
42
+ {/* Input + send */}
43
+ <div className="flex items-end gap-2">
44
+ <div className="flex-1">
45
+ <TextareaField
46
+ value={message}
47
+ onChange={(val) => setMessage(val ?? "")}
48
+ placeholder={placeholder}
49
+ rows={1}
50
+ />
51
+ </div>
52
+ <Button
53
+ size="sm"
54
+ icon={Send}
55
+ onClick={handleSend}
56
+ disabled={!message.trim()}
57
+ />
58
+ </div>
59
+
60
+ {/* Toolbar */}
61
+ <div className="flex items-center gap-1.5">
62
+ <div className="w-[130px]">
63
+ <SearchSelect
64
+ value={model}
65
+ onChange={setModel}
66
+ options={models}
67
+ placeholder="Model..."
68
+ position="absolute"
69
+ direction="up"
70
+ size="sm"
71
+ allowClear={false}
72
+ />
73
+ </div>
74
+
75
+ <Button
76
+ variant={webSearch ? "default" : "ghost"}
77
+ size="xs"
78
+ icon={Globe}
79
+ label={webSearchLabel}
80
+ onClick={() => setWebSearch((v) => !v)}
81
+ />
82
+
83
+ {toolbar}
84
+ </div>
85
+ </Box>
86
+ );
87
+ }
@@ -0,0 +1,104 @@
1
+ import { Button } from "../button/button";
2
+ import { Bot, User, MessageSquare } from "lucide-react";
3
+ import type { ChatMessageData } from "./types";
4
+ import { ToolUseBlock } from "./tool-use-block";
5
+
6
+ interface ChatMessageProps {
7
+ message: ChatMessageData;
8
+ onAnswerQuestions?: () => void;
9
+ onToolUseClick?: (link: string) => void;
10
+ /** Label for questions count, e.g. "questions to answer" */
11
+ questionsLabel?: string;
12
+ /** Label for the answer button */
13
+ answerLabel?: string;
14
+ }
15
+
16
+ export function ChatMessage({
17
+ message,
18
+ onAnswerQuestions,
19
+ onToolUseClick,
20
+ questionsLabel = "questions to answer",
21
+ answerLabel = "Answer",
22
+ }: ChatMessageProps) {
23
+ const isUser = message.role === "user";
24
+ const hasUnansweredQuestions =
25
+ message.questions && message.questions.length > 0;
26
+
27
+ return (
28
+ <div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
29
+ {/* Avatar */}
30
+ <div
31
+ className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full mt-0.5 ${
32
+ isUser ? "bg-primary/10" : "bg-muted"
33
+ }`}
34
+ >
35
+ {isUser ? (
36
+ <User className="h-3.5 w-3.5 text-primary" />
37
+ ) : (
38
+ <Bot className="h-3.5 w-3.5 text-muted-foreground" />
39
+ )}
40
+ </div>
41
+
42
+ {/* Content */}
43
+ <div
44
+ className={`flex-1 min-w-0 space-y-2 ${isUser ? "text-right" : ""}`}
45
+ >
46
+ <div
47
+ className={`inline-block rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
48
+ isUser
49
+ ? "bg-primary/10 text-foreground rounded-tr-sm"
50
+ : "bg-card border border-border rounded-tl-sm"
51
+ }`}
52
+ >
53
+ {message.content.split("\n").map((line, i) => (
54
+ <p key={i} className={i > 0 ? "mt-1.5" : ""}>
55
+ {line.startsWith("• ") ? (
56
+ <span className="flex items-start gap-1.5 text-left">
57
+ <span className="text-primary mt-px">•</span>
58
+ <span>{line.slice(2)}</span>
59
+ </span>
60
+ ) : (
61
+ line
62
+ )}
63
+ </p>
64
+ ))}
65
+ {message.isStreaming && (
66
+ <span className="inline-block w-1.5 h-4 bg-foreground/60 animate-pulse ml-0.5 -mb-0.5" />
67
+ )}
68
+ </div>
69
+
70
+ {/* Tool uses */}
71
+ {message.toolUses && message.toolUses.length > 0 && (
72
+ <div className="space-y-1.5">
73
+ {message.toolUses.map((tu, i) => (
74
+ <ToolUseBlock
75
+ key={i}
76
+ toolUse={tu}
77
+ onClick={
78
+ tu.link && onToolUseClick
79
+ ? () => onToolUseClick(tu.link!)
80
+ : undefined
81
+ }
82
+ />
83
+ ))}
84
+ </div>
85
+ )}
86
+
87
+ {/* Questions indicator */}
88
+ {hasUnansweredQuestions && onAnswerQuestions && (
89
+ <div className="flex items-center gap-2">
90
+ <span className="text-xs text-muted-foreground">
91
+ {message.questions!.length} {questionsLabel}
92
+ </span>
93
+ <Button
94
+ size="xs"
95
+ icon={MessageSquare}
96
+ label={answerLabel}
97
+ onClick={onAnswerQuestions}
98
+ />
99
+ </div>
100
+ )}
101
+ </div>
102
+ </div>
103
+ );
104
+ }
@@ -0,0 +1,135 @@
1
+ import { useState, useRef, useEffect, type ReactNode } from "react";
2
+ import type {
3
+ ChatMessageData,
4
+ ChatModel,
5
+ QuestionAnswers,
6
+ SendMessageOptions,
7
+ } from "./types";
8
+ import { ChatMessage } from "./chat-message";
9
+ import { ChatInput } from "./chat-input";
10
+ import { QuestionTabs } from "./question-tabs";
11
+
12
+ export interface ChatProps {
13
+ /** Message history */
14
+ messages: ChatMessageData[];
15
+ /** Available AI models */
16
+ models: ChatModel[];
17
+ /** Default model to use */
18
+ defaultModel?: string;
19
+ /** Called when user sends a message */
20
+ onSend: (message: string, options: SendMessageOptions) => void;
21
+ /** Called when user submits answers to questions */
22
+ onAnswerQuestions?: (
23
+ messageId: string,
24
+ answers: QuestionAnswers,
25
+ ) => void;
26
+ /** Called when user clicks a tool use link */
27
+ onToolUseClick?: (link: string) => void;
28
+ /** Header content (title, description) */
29
+ header?: ReactNode;
30
+ /** Sidebar content (consultation plan, etc.) */
31
+ sidebar?: ReactNode;
32
+ /** Extra toolbar content for ChatInput (attach menu, etc.) */
33
+ toolbar?: ReactNode;
34
+ /** Max width class for the message area */
35
+ maxWidth?: string;
36
+ /** Labels for i18n */
37
+ labels?: {
38
+ questionsLabel?: string;
39
+ answerLabel?: string;
40
+ webSearchLabel?: string;
41
+ placeholder?: string;
42
+ submitLabel?: string;
43
+ completedLabel?: string;
44
+ customPlaceholder?: string;
45
+ };
46
+ }
47
+
48
+ export function Chat({
49
+ messages,
50
+ models,
51
+ defaultModel,
52
+ onSend,
53
+ onAnswerQuestions,
54
+ onToolUseClick,
55
+ header,
56
+ sidebar,
57
+ toolbar,
58
+ maxWidth = "max-w-3xl",
59
+ labels,
60
+ }: ChatProps) {
61
+ const [answeringMessageId, setAnsweringMessageId] = useState<string | null>(
62
+ null,
63
+ );
64
+ const messagesEndRef = useRef<HTMLDivElement>(null);
65
+
66
+ const answeringMessage = answeringMessageId
67
+ ? messages.find((m) => m.id === answeringMessageId)
68
+ : null;
69
+
70
+ // Auto-scroll on new messages
71
+ useEffect(() => {
72
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
73
+ }, [messages.length]);
74
+
75
+ return (
76
+ <div className="flex gap-6">
77
+ {/* Sidebar */}
78
+ {sidebar && (
79
+ <div className="hidden lg:block w-64 shrink-0 sticky top-24 self-start">
80
+ {sidebar}
81
+ </div>
82
+ )}
83
+
84
+ {/* Main chat area */}
85
+ <div className="flex-1 min-w-0 space-y-4">
86
+ {/* Header */}
87
+ {header}
88
+
89
+ {/* Messages */}
90
+ <div className={`${maxWidth} mx-auto space-y-4`}>
91
+ {messages.map((msg) => (
92
+ <ChatMessage
93
+ key={msg.id}
94
+ message={msg}
95
+ onAnswerQuestions={
96
+ msg.questions?.length
97
+ ? () => setAnsweringMessageId(msg.id)
98
+ : undefined
99
+ }
100
+ onToolUseClick={onToolUseClick}
101
+ questionsLabel={labels?.questionsLabel}
102
+ answerLabel={labels?.answerLabel}
103
+ />
104
+ ))}
105
+ <div ref={messagesEndRef} />
106
+ </div>
107
+
108
+ {/* Input area — sticky at bottom */}
109
+ <div className={`sticky bottom-0 ${maxWidth} mx-auto w-full pb-4`}>
110
+ {answeringMessage?.questions ? (
111
+ <QuestionTabs
112
+ questions={answeringMessage.questions}
113
+ onSubmit={(answers) => {
114
+ onAnswerQuestions?.(answeringMessageId!, answers);
115
+ setAnsweringMessageId(null);
116
+ }}
117
+ submitLabel={labels?.submitLabel}
118
+ completedLabel={labels?.completedLabel}
119
+ customPlaceholder={labels?.customPlaceholder}
120
+ />
121
+ ) : (
122
+ <ChatInput
123
+ onSend={onSend}
124
+ models={models}
125
+ defaultModel={defaultModel}
126
+ toolbar={toolbar}
127
+ webSearchLabel={labels?.webSearchLabel}
128
+ placeholder={labels?.placeholder}
129
+ />
130
+ )}
131
+ </div>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,170 @@
1
+ import { Box } from "../box/box";
2
+ import { Button } from "../button/button";
3
+ import { TextareaField } from "../form";
4
+ import { Send, Check } from "lucide-react";
5
+ import { useState } from "react";
6
+ import type { Question, QuestionAnswers } from "./types";
7
+
8
+ interface QuestionTabsProps {
9
+ questions: Question[];
10
+ onSubmit: (answers: QuestionAnswers) => void;
11
+ /** Label for submit button */
12
+ submitLabel?: string;
13
+ /** Label for completed count, e.g. "completed" */
14
+ completedLabel?: string;
15
+ /** Placeholder for custom answer input */
16
+ customPlaceholder?: string;
17
+ }
18
+
19
+ export function QuestionTabs({
20
+ questions,
21
+ onSubmit,
22
+ submitLabel = "Submit",
23
+ completedLabel = "completed",
24
+ customPlaceholder = "Custom answer...",
25
+ }: QuestionTabsProps) {
26
+ const [activeTab, setActiveTab] = useState(0);
27
+ const [answers, setAnswers] = useState<QuestionAnswers>(
28
+ () =>
29
+ Object.fromEntries(
30
+ questions.map((q) => [q.id, { selected: [], text: "" }]),
31
+ ),
32
+ );
33
+
34
+ const current = questions[activeTab];
35
+ const currentAnswer = answers[current.id] ?? { selected: [], text: "" };
36
+
37
+ const toggleOption = (option: string) => {
38
+ const selected = currentAnswer.selected.includes(option)
39
+ ? currentAnswer.selected.filter((o) => o !== option)
40
+ : [...currentAnswer.selected, option];
41
+ setAnswers((prev) => ({
42
+ ...prev,
43
+ [current.id]: { ...prev[current.id], selected },
44
+ }));
45
+ };
46
+
47
+ const setText = (text: string) => {
48
+ setAnswers((prev) => ({
49
+ ...prev,
50
+ [current.id]: { ...prev[current.id], text },
51
+ }));
52
+ };
53
+
54
+ const hasCustomText = currentAnswer.text.trim().length > 0;
55
+
56
+ const totalAnswered = questions.filter(
57
+ (q) =>
58
+ (answers[q.id]?.selected.length ?? 0) > 0 ||
59
+ (answers[q.id]?.text ?? "").trim().length > 0,
60
+ ).length;
61
+
62
+ return (
63
+ <Box className="p-0 overflow-hidden">
64
+ {/* Tabs */}
65
+ <div className="flex border-b border-border overflow-x-auto">
66
+ {questions.map((q, i) => {
67
+ const hasAnswer =
68
+ (answers[q.id]?.selected.length ?? 0) > 0 ||
69
+ (answers[q.id]?.text ?? "").trim().length > 0;
70
+ return (
71
+ <button
72
+ key={q.id}
73
+ type="button"
74
+ onClick={() => setActiveTab(i)}
75
+ className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium whitespace-nowrap transition-colors border-b-2 -mb-px ${
76
+ i === activeTab
77
+ ? "border-primary text-primary"
78
+ : "border-transparent text-muted-foreground hover:text-foreground"
79
+ }`}
80
+ >
81
+ {hasAnswer && (
82
+ <span className="h-1.5 w-1.5 rounded-full bg-primary shrink-0" />
83
+ )}
84
+ {q.label}
85
+ </button>
86
+ );
87
+ })}
88
+ </div>
89
+
90
+ {/* Content */}
91
+ <div className="p-3 space-y-1.5">
92
+ <p className="text-xs text-muted-foreground mb-2">
93
+ {current.description}
94
+ </p>
95
+
96
+ {/* Options */}
97
+ {current.options.map((option) => {
98
+ const isSelected = currentAnswer.selected.includes(option);
99
+ return (
100
+ <button
101
+ key={option}
102
+ type="button"
103
+ onClick={() => toggleOption(option)}
104
+ className={`flex items-center gap-2.5 w-full rounded-lg px-3 py-2.5 text-left transition-colors ${
105
+ isSelected
106
+ ? "bg-primary/10 border border-primary/20"
107
+ : "bg-muted/50 border border-transparent hover:bg-muted"
108
+ }`}
109
+ >
110
+ <div
111
+ className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
112
+ isSelected
113
+ ? "border-primary bg-primary"
114
+ : "border-input bg-transparent"
115
+ }`}
116
+ >
117
+ {isSelected && (
118
+ <Check className="h-3 w-3 text-primary-foreground" />
119
+ )}
120
+ </div>
121
+ <span className="text-sm">{option}</span>
122
+ </button>
123
+ );
124
+ })}
125
+
126
+ {/* Custom option */}
127
+ <div
128
+ className={`flex items-start gap-2.5 w-full rounded-lg px-3 py-2.5 transition-colors ${
129
+ hasCustomText
130
+ ? "bg-primary/10 border border-primary/20"
131
+ : "bg-muted/50 border border-transparent"
132
+ }`}
133
+ >
134
+ <div
135
+ className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors mt-0.5 ${
136
+ hasCustomText
137
+ ? "border-primary bg-primary"
138
+ : "border-input bg-transparent"
139
+ }`}
140
+ >
141
+ {hasCustomText && (
142
+ <Check className="h-3 w-3 text-primary-foreground" />
143
+ )}
144
+ </div>
145
+ <div className="flex-1">
146
+ <TextareaField
147
+ value={currentAnswer.text}
148
+ onChange={(val) => setText(val ?? "")}
149
+ placeholder={customPlaceholder}
150
+ rows={1}
151
+ />
152
+ </div>
153
+ </div>
154
+ </div>
155
+
156
+ {/* Footer */}
157
+ <div className="flex items-center justify-between border-t border-border px-3 py-2">
158
+ <span className="text-[10px] text-muted-foreground">
159
+ {totalAnswered}/{questions.length} {completedLabel}
160
+ </span>
161
+ <Button
162
+ size="sm"
163
+ icon={Send}
164
+ label={submitLabel}
165
+ onClick={() => onSubmit(answers)}
166
+ />
167
+ </div>
168
+ </Box>
169
+ );
170
+ }
@@ -0,0 +1,34 @@
1
+ import { CheckCircle2, ArrowRight } from "lucide-react";
2
+ import type { ToolUse } from "./types";
3
+
4
+ interface ToolUseBlockProps {
5
+ toolUse: ToolUse;
6
+ onClick?: () => void;
7
+ }
8
+
9
+ export function ToolUseBlock({ toolUse, onClick }: ToolUseBlockProps) {
10
+ const Component = onClick ? "button" : "div";
11
+
12
+ return (
13
+ <Component
14
+ type={onClick ? "button" : undefined}
15
+ onClick={onClick}
16
+ className={`flex items-start gap-3 w-full rounded-xl border border-green-500/20 bg-green-500/5 p-3 text-left transition-colors ${
17
+ onClick ? "hover:bg-green-500/10 cursor-pointer" : ""
18
+ }`}
19
+ >
20
+ <CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
21
+ <div className="flex-1 min-w-0">
22
+ <p className="text-xs font-medium text-green-700 dark:text-green-400">
23
+ {toolUse.action}
24
+ </p>
25
+ <p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
26
+ {toolUse.value}
27
+ </p>
28
+ </div>
29
+ {onClick && (
30
+ <ArrowRight className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
31
+ )}
32
+ </Component>
33
+ );
34
+ }
@@ -0,0 +1,36 @@
1
+ export interface ToolUse {
2
+ action: string;
3
+ value: string;
4
+ link?: string;
5
+ }
6
+
7
+ export interface Question {
8
+ id: string;
9
+ label: string;
10
+ description: string;
11
+ options: string[];
12
+ }
13
+
14
+ export interface QuestionAnswers {
15
+ [questionId: string]: { selected: string[]; text: string };
16
+ }
17
+
18
+ export interface ChatMessageData {
19
+ id: string;
20
+ role: "user" | "assistant" | "system" | "tool";
21
+ content: string;
22
+ toolUses?: ToolUse[];
23
+ questions?: Question[];
24
+ isStreaming?: boolean;
25
+ }
26
+
27
+ export interface ChatModel {
28
+ value: string;
29
+ label: string;
30
+ }
31
+
32
+ export interface SendMessageOptions {
33
+ model: string;
34
+ webSearch: boolean;
35
+ attachments?: string[];
36
+ }
package/src/index.ts CHANGED
@@ -99,6 +99,22 @@ export { ExpandablePanel } from "./layout/expandable-panel";
99
99
  export { useExpandable } from "./layout/use-expandable";
100
100
  export type { UseExpandableReturn } from "./layout/use-expandable";
101
101
 
102
+ // Chat
103
+ export { Chat } from "./ds/chat/chat";
104
+ export type { ChatProps } from "./ds/chat/chat";
105
+ export { ChatMessage } from "./ds/chat/chat-message";
106
+ export { ChatInput } from "./ds/chat/chat-input";
107
+ export { QuestionTabs } from "./ds/chat/question-tabs";
108
+ export { ToolUseBlock } from "./ds/chat/tool-use-block";
109
+ export type {
110
+ ChatMessageData,
111
+ ChatModel,
112
+ SendMessageOptions,
113
+ ToolUse,
114
+ Question,
115
+ QuestionAnswers,
116
+ } from "./ds/chat/types";
117
+
102
118
  // Types
103
119
  export type {
104
120
  AvatarProps,