Skip to content

Commit 12d62bb

Browse files
committed
more ui
1 parent 6bb920b commit 12d62bb

File tree

19 files changed

+660
-279
lines changed

19 files changed

+660
-279
lines changed

packages/ai/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"typecheck": "tsc --noEmit"
2323
},
2424
"dependencies": {
25-
"@spaceui/primitives": "file:../primitives",
25+
"@spaceui/primitives": "^0.0.1",
2626
"@phosphor-icons/react": "^2.1.0",
2727
"react-markdown": "^9.0.0",
2828
"remark-gfm": "^4.0.0",

packages/ai/src/ChatComposer.tsx

Lines changed: 163 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,137 +1,177 @@
1-
import { clsx } from 'clsx';
2-
import { forwardRef, useState, useRef, useEffect } from 'react';
3-
import { PaperPlaneRight, Microphone, Image as ImageIcon } from '@phosphor-icons/react';
4-
import { Button } from '@spaceui/primitives';
5-
import type { ModelOption } from './types';
1+
import {clsx} from "clsx";
2+
import {forwardRef, useMemo, useState} from "react";
3+
import {Microphone, Image as ImageIcon} from "@phosphor-icons/react";
4+
import {
5+
OptionList,
6+
OptionListItem,
7+
Popover,
8+
PopoverContent,
9+
PopoverTrigger,
10+
SelectTriggerButton,
11+
} from "@spaceui/primitives";
12+
import type {ModelOption} from "./types";
613

714
interface ChatComposerProps {
8-
value: string;
9-
onChange: (value: string) => void;
10-
onSend: () => void;
11-
onVoiceClick?: () => void;
12-
onImageClick?: () => void;
13-
disabled?: boolean;
14-
placeholder?: string;
15-
models?: ModelOption[];
16-
selectedModel?: string;
17-
onModelChange?: (model: string) => void;
18-
className?: string;
15+
value: string;
16+
onChange: (value: string) => void;
17+
onSend: () => void;
18+
footerStart?: React.ReactNode;
19+
onVoiceClick?: () => void;
20+
onImageClick?: () => void;
21+
disabled?: boolean;
22+
placeholder?: string;
23+
models?: ModelOption[];
24+
selectedModel?: string;
25+
onModelChange?: (model: string) => void;
26+
className?: string;
1927
}
2028

2129
const ChatComposer = forwardRef<HTMLDivElement, ChatComposerProps>(
22-
({
23-
value,
24-
onChange,
25-
onSend,
26-
onVoiceClick,
27-
onImageClick,
28-
disabled = false,
29-
placeholder = 'Type a message...',
30-
models,
31-
selectedModel,
32-
onModelChange,
33-
className
34-
}, ref) => {
35-
const [isFocused, setIsFocused] = useState(false);
36-
const textareaRef = useRef<HTMLTextAreaElement>(null);
30+
(
31+
{
32+
value,
33+
onChange,
34+
onSend,
35+
footerStart,
36+
onVoiceClick,
37+
onImageClick,
38+
disabled = false,
39+
placeholder = "Ask the agent to review a project, plan work, or start a task...",
40+
models,
41+
selectedModel,
42+
onModelChange,
43+
className,
44+
},
45+
ref,
46+
) => {
47+
const [isFocused, setIsFocused] = useState(false);
48+
const [modelOpen, setModelOpen] = useState(false);
3749

38-
useEffect(() => {
39-
if (textareaRef.current) {
40-
textareaRef.current.style.height = 'auto';
41-
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
42-
}
43-
}, [value]);
50+
const isExpanded = isFocused || value.trim().length > 0;
51+
const canSend = !disabled && value.trim().length > 0;
4452

45-
const handleKeyDown = (e: React.KeyboardEvent) => {
46-
if (e.key === 'Enter' && !e.shiftKey) {
47-
e.preventDefault();
48-
if (!disabled && value.trim()) {
49-
onSend();
50-
}
51-
}
52-
};
53+
const selectedModelOption = useMemo(
54+
() => models?.find((model) => model.id === selectedModel),
55+
[models, selectedModel],
56+
);
5357

54-
return (
55-
<div
56-
ref={ref}
57-
className={clsx(
58-
'rounded-xl border border-app-line bg-app-box transition-all',
59-
isFocused && 'border-accent ring-2 ring-accent/20',
60-
className
61-
)}
62-
>
63-
{models && onModelChange && (
64-
<div className="flex items-center gap-2 border-b border-app-line px-3 py-2">
65-
<select
66-
value={selectedModel}
67-
onChange={(e) => onModelChange(e.target.value)}
68-
className="bg-transparent text-xs text-ink-dull focus:outline-none"
69-
>
70-
{models.map((model) => (
71-
<option key={model.id} value={model.id}>
72-
{model.name}
73-
</option>
74-
))}
75-
</select>
76-
</div>
77-
)}
58+
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
59+
if (event.key === "Enter" && !event.shiftKey) {
60+
event.preventDefault();
61+
if (canSend) {
62+
onSend();
63+
}
64+
}
65+
};
7866

79-
<div className="p-3">
80-
<textarea
81-
ref={textareaRef}
82-
value={value}
83-
onChange={(e) => onChange(e.target.value)}
84-
onFocus={() => setIsFocused(true)}
85-
onBlur={() => setIsFocused(false)}
86-
onKeyDown={handleKeyDown}
87-
placeholder={placeholder}
88-
disabled={disabled}
89-
rows={1}
90-
className="w-full resize-none bg-transparent text-sm text-ink placeholder:text-ink-faint focus:outline-none min-h-[24px] max-h-[200px]"
91-
/>
67+
return (
68+
<div
69+
ref={ref}
70+
className={clsx(
71+
"rounded-[28px] border border-app-line bg-app-box/70 p-4 shadow-[0_30px_80px_rgba(0,0,0,0.22)] backdrop-blur-2xl",
72+
className,
73+
)}
74+
>
75+
<div
76+
className={clsx(
77+
"overflow-hidden rounded-[20px] border border-app-line bg-app p-4 transition-all duration-200",
78+
isExpanded ? "min-h-[228px]" : "min-h-[186px]",
79+
)}
80+
>
81+
<textarea
82+
value={value}
83+
onChange={(event) => onChange(event.target.value)}
84+
onFocus={() => setIsFocused(true)}
85+
onBlur={() => setIsFocused(false)}
86+
onKeyDown={handleKeyDown}
87+
placeholder={placeholder}
88+
disabled={disabled}
89+
rows={4}
90+
className="h-[120px] w-full resize-none border-0 bg-transparent text-[15px] leading-7 text-ink outline-none ring-0 placeholder:text-ink-faint focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-60"
91+
/>
9292

93-
<div className="mt-2 flex items-center justify-between">
94-
<div className="flex items-center gap-1">
95-
{onVoiceClick && (
96-
<Button
97-
variant="ghost"
98-
size="icon"
99-
className="size-7"
100-
onClick={onVoiceClick}
101-
disabled={disabled}
102-
>
103-
<Microphone className="size-4" />
104-
</Button>
105-
)}
106-
{onImageClick && (
107-
<Button
108-
variant="ghost"
109-
size="icon"
110-
className="size-7"
111-
onClick={onImageClick}
112-
disabled={disabled}
113-
>
114-
<ImageIcon className="size-4" />
115-
</Button>
116-
)}
117-
</div>
93+
<div className="mt-5 flex items-end justify-between gap-3">
94+
<div className="min-w-0 flex-1">{footerStart ?? null}</div>
11895

119-
<Button
120-
variant="default"
121-
size="icon"
122-
className="size-7"
123-
onClick={onSend}
124-
disabled={disabled || !value.trim()}
125-
>
126-
<PaperPlaneRight className="size-4" />
127-
</Button>
128-
</div>
129-
</div>
130-
</div>
131-
);
132-
}
96+
<div className="flex items-center gap-2">
97+
{models && models.length > 0 && onModelChange ? (
98+
<Popover open={modelOpen} onOpenChange={setModelOpen}>
99+
<PopoverTrigger asChild>
100+
<SelectTriggerButton
101+
disabled={disabled}
102+
placeholder="Select model"
103+
>
104+
{selectedModelOption?.name}
105+
</SelectTriggerButton>
106+
</PopoverTrigger>
107+
<PopoverContent
108+
align="end"
109+
sideOffset={8}
110+
className="min-w-[240px] p-2"
111+
>
112+
<OptionList>
113+
{models.map((model) => (
114+
<OptionListItem
115+
key={model.id}
116+
onClick={() => {
117+
onModelChange(model.id);
118+
setModelOpen(false);
119+
}}
120+
selected={model.id === selectedModel}
121+
>
122+
{model.name}
123+
</OptionListItem>
124+
))}
125+
</OptionList>
126+
</PopoverContent>
127+
</Popover>
128+
) : null}
129+
130+
{onImageClick ? (
131+
<button
132+
type="button"
133+
onClick={onImageClick}
134+
disabled={disabled}
135+
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-app-line bg-app-box text-ink-dull transition-colors hover:bg-app-hover hover:text-ink disabled:cursor-not-allowed disabled:opacity-60"
136+
>
137+
<ImageIcon className="size-4" weight="regular" />
138+
</button>
139+
) : null}
140+
141+
{onVoiceClick ? (
142+
<button
143+
type="button"
144+
onClick={onVoiceClick}
145+
disabled={disabled}
146+
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-app-line bg-app-box text-ink-dull transition-colors hover:bg-app-hover hover:text-ink disabled:cursor-not-allowed disabled:opacity-60"
147+
>
148+
<Microphone className="size-4" weight="fill" />
149+
</button>
150+
) : null}
151+
152+
<div
153+
className={clsx(
154+
"overflow-hidden transition-all duration-200",
155+
canSend ? "w-[76px] opacity-100" : "w-0 opacity-0",
156+
)}
157+
>
158+
<button
159+
type="button"
160+
onClick={onSend}
161+
disabled={!canSend}
162+
className="flex h-11 w-[76px] items-center justify-center rounded-full border border-app-line bg-accent px-4 text-xs font-medium text-white transition-colors hover:bg-accent-faint disabled:cursor-not-allowed disabled:opacity-60"
163+
>
164+
<span className="whitespace-nowrap">Send</span>
165+
</button>
166+
</div>
167+
</div>
168+
</div>
169+
</div>
170+
</div>
171+
);
172+
},
133173
);
134174

135-
ChatComposer.displayName = 'ChatComposer';
175+
ChatComposer.displayName = "ChatComposer";
136176

137-
export { ChatComposer };
177+
export {ChatComposer};

packages/ai/src/InlineWorkerCard.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ interface InlineWorkerCardProps {
1111
transcript: TranscriptStep[];
1212
onCopyLogs?: () => void;
1313
onCancel?: () => void;
14+
expanded?: boolean;
15+
onExpandedChange?: (expanded: boolean) => void;
1416
className?: string;
1517
}
1618

1719
const InlineWorkerCard = forwardRef<HTMLDivElement, InlineWorkerCardProps>(
18-
({ task, transcript, onCopyLogs, onCancel, className }, ref) => {
19-
const [expanded, setExpanded] = useState(false);
20+
({ task, transcript, onCopyLogs, onCancel, expanded: expandedProp, onExpandedChange, className }, ref) => {
21+
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
2022
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
23+
const expanded = expandedProp ?? uncontrolledExpanded;
2124

2225
const pairs = pairTranscriptSteps(transcript);
2326
const toolCallCount = transcript.filter(t => t.type === 'action').length;
@@ -32,6 +35,13 @@ const InlineWorkerCard = forwardRef<HTMLDivElement, InlineWorkerCardProps>(
3235
setExpandedTools(newSet);
3336
};
3437

38+
const setExpanded = (nextExpanded: boolean) => {
39+
if (expandedProp === undefined) {
40+
setUncontrolledExpanded(nextExpanded);
41+
}
42+
onExpandedChange?.(nextExpanded);
43+
};
44+
3545
const statusColors: Record<string, string> = {
3646
pending: 'bg-accent/10 text-accent',
3747
running: 'bg-accent/10 text-accent',

packages/explorer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"typecheck": "tsc --noEmit"
2323
},
2424
"dependencies": {
25-
"@spaceui/primitives": "workspace:*",
25+
"@spaceui/primitives": "^0.0.1",
2626
"@phosphor-icons/react": "^2.1.0",
2727
"clsx": "^2.1.0"
2828
},

packages/forms/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"typecheck": "tsc --noEmit"
2323
},
2424
"dependencies": {
25-
"@spaceui/primitives": "workspace:*",
25+
"@spaceui/primitives": "^0.0.1",
2626
"clsx": "^2.1.0"
2727
},
2828
"devDependencies": {

0 commit comments

Comments
 (0)