Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/client/soma/web/src/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function ChatHeader(props: {
}

return (
<Pane border="bottom" padding="lg" tone="raised">
<Pane padding="lg">
<Inline justify="between" align="start" wrap gap="md">
<Stack gap="xs">
<Text size="xs" tone="subtle">SESSION</Text>
Expand Down
52 changes: 23 additions & 29 deletions apps/client/soma/web/src/components/ChatShell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GridRows, Notice, Pane, Stack, Text } from "@mini-stim/components";
import { Pane, SectionStackLayout } from "@mini-stim/components";

import { ChatHeader } from "./ChatHeader";
import { Composer } from "./Composer";
Expand All @@ -19,34 +19,28 @@ export function ChatShell(props: {
}) {
return (
<Pane chrome="panel" tone="subtle" grow>
<GridRows grow template="header-body-footer">
<ChatHeader
busy={props.busy}
connection={props.connection}
onTitleCommit={props.onTitleCommit}
selectedSessionId={props.selectedSessionId}
title={props.title}
titleValue={props.titleValue}
/>
<Transcript timeline={props.timeline} />
<Stack gap="none">
{props.error ? (
<Pane padding="md">
<Notice tone="danger">
<Text>{props.error}</Text>
</Notice>
</Pane>
) : null}
<Pane border="top" padding="md" tone="raised">
<Composer
value={props.draft}
disabled={props.busy}
onChange={props.onDraftChange}
onSubmit={props.onSend}
/>
</Pane>
</Stack>
</GridRows>
<SectionStackLayout
top={(
<ChatHeader
busy={props.busy}
connection={props.connection}
onTitleCommit={props.onTitleCommit}
selectedSessionId={props.selectedSessionId}
title={props.title}
titleValue={props.titleValue}
/>
)}
middle={<Transcript timeline={props.timeline} />}
bottom={(
<Composer
value={props.draft}
disabled={props.busy}
error={props.error}
onChange={props.onDraftChange}
onSubmit={props.onSend}
/>
)}
/>
</Pane>
);
}
56 changes: 31 additions & 25 deletions apps/client/soma/web/src/components/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Button, FieldActionLayout, SendIcon, TextArea } from "@mini-stim/components";
import { Button, Inline, Notice, SendIcon, Stack, Text, TextArea } from "@mini-stim/components";

export function Composer(props: {
disabled?: boolean;
error?: string | null;
onChange: (value: string) => void;
onSubmit: () => void;
value: string;
Expand All @@ -13,30 +14,35 @@ export function Composer(props: {
props.onSubmit();
}}
>
<FieldActionLayout
actionWidth="lg"
action={(
<Button
type="submit"
tone="accent"
size="lg"
disabled={props.disabled || !props.value.trim()}
>
<SendIcon size="sm" />
Send
</Button>
)}
>
<TextArea
autosize={{ min: 1, max: 6 }}
value={props.value}
disabled={props.disabled}
placeholder="Message mini-stim and press Enter"
resize="none"
variant="composer"
onChange={(event) => props.onChange(event.currentTarget.value)}
/>
</FieldActionLayout>
<Stack gap="sm">
{props.error ? (
<Notice tone="danger">
<Text>{props.error}</Text>
</Notice>
) : null}
<Inline align="stretch" gap="sm">
<Stack grow gap="none">
<TextArea
autosize={{ min: 1, max: 6 }}
value={props.value}
disabled={props.disabled}
placeholder="Message mini-stim and press Enter"
resize="none"
variant="composer"
onChange={(event) => props.onChange(event.currentTarget.value)}
/>
</Stack>
<Button
type="submit"
tone="accent"
size="lg"
disabled={props.disabled || !props.value.trim()}
>
<SendIcon size="sm" />
Send
</Button>
</Inline>
</Stack>
</form>
);
}
38 changes: 38 additions & 0 deletions packages/components/src/atoms/TextArea/TextArea.scss
Original file line number Diff line number Diff line change
@@ -1,9 +1,42 @@
@use "../../theme/mixins" as *;

.msTextAreaFrame {
--ms-textarea-frame-border: var(--ms-color-input-border);
--ms-textarea-frame-bg: var(--ms-color-input-bg);
--ms-textarea-frame-radius: var(--ms-radius-lg);
--ms-textarea-frame-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);

display: flex;
align-items: flex-end;
width: 100%;
min-width: 0;
overflow: hidden;
border: 1px solid var(--ms-textarea-frame-border);
border-radius: var(--ms-textarea-frame-radius);
background: var(--ms-textarea-frame-bg);
box-shadow: var(--ms-textarea-frame-shadow);
transition: height 180ms ease-out;

@include focus-ring("&:focus-within");

> .msTextArea {
border: 0;
border-radius: inherit;
background: transparent;
box-shadow: none;

&:focus {
box-shadow: none;
}
}
}

.msTextArea {
display: block;
width: 100%;
min-height: 5.25rem;
overscroll-behavior: contain;
scrollbar-gutter: stable;
border: 1px solid var(--ms-color-input-border);
border-radius: var(--ms-radius-lg);
background: var(--ms-color-input-bg);
Expand All @@ -30,6 +63,11 @@
}

&--variant-composer {
--ms-textarea-frame-border: var(--ms-color-dock-border);
--ms-textarea-frame-bg: var(--ms-color-input-dock-bg);
--ms-textarea-frame-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 1px 0 var(--ms-color-border-faint);
min-height: 3.5rem;
border-color: var(--ms-color-dock-border);
background: var(--ms-color-input-dock-bg);
Expand Down
77 changes: 20 additions & 57 deletions packages/components/src/atoms/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import {
useLayoutEffect,
useRef,
type TextareaHTMLAttributes,
} from "react";
import { type TextareaHTMLAttributes } from "react";

import { cx } from "../../internal/cx";
import {
type TextAreaAutosize,
useTextAreaAutosize,
} from "./hooks/useTextAreaAutosize";
import "./TextArea.scss";

type TextAreaAutosize =
| boolean
| {
max: number;
min: number;
};

type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
autosize?: TextAreaAutosize;
resize?: "none" | "vertical";
Expand All @@ -30,37 +23,14 @@ export function TextArea({
value,
...props
}: TextAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const autosizeConfig = resolveAutosize(autosize);
const resolvedRows = autosizeConfig?.min ?? rows;

useLayoutEffect(() => {
const element = textareaRef.current;
if (!element || !autosizeConfig) {
return;
}

const computed = window.getComputedStyle(element);
const borderBox =
parseFloat(computed.borderTopWidth) + parseFloat(computed.borderBottomWidth);
const paddingBox =
parseFloat(computed.paddingTop) + parseFloat(computed.paddingBottom);
const lineHeight = parseFloat(computed.lineHeight);

if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
return;
}

const minHeight = lineHeight * autosizeConfig.min + paddingBox + borderBox;
const maxHeight = lineHeight * autosizeConfig.max + paddingBox + borderBox;

element.style.height = "auto";
const nextHeight = Math.min(Math.max(element.scrollHeight, minHeight), maxHeight);
element.style.height = `${nextHeight}px`;
element.style.overflowY = element.scrollHeight > maxHeight ? "auto" : "hidden";
}, [autosizeConfig, value]);

return (
const { autosizeConfig, frameElementRef, resolvedRows, textareaRef } =
useTextAreaAutosize({
autosize,
rows,
value,
});

const textarea = (
<textarea
{...props}
ref={textareaRef}
Expand All @@ -76,21 +46,14 @@ export function TextArea({
)}
/>
);
}

function resolveAutosize(input: TextAreaAutosize) {
if (!input) {
return null;
}

if (input === true) {
return { min: 1, max: 6 };
if (!autosizeConfig) {
return textarea;
}

const min = Number.isFinite(input.min) ? Math.max(1, Math.floor(input.min)) : 1;
const max = Number.isFinite(input.max)
? Math.max(min, Math.floor(input.max))
: Math.max(min, 6);

return { min, max };
return (
<div ref={frameElementRef} className="msTextAreaFrame">
{textarea}
</div>
);
}
Loading
Loading