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
8 changes: 7 additions & 1 deletion desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export function AppShell() {
? `${activeChannel.description} Forum channels are listed, but this first pass only wires message streams and DMs.`
: activeChannel.description
: "Connect to the relay to browse channels and read messages.";
const contentPaneKey =
selectedView === "home" ? "home" : `channel:${activeChannel?.id ?? "none"}`;

return (
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
Expand Down Expand Up @@ -108,7 +110,10 @@ export function AppShell() {
selectedView={selectedView}
/>

<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<SidebarInset
className="min-h-0 min-w-0 overflow-hidden"
key={contentPaneKey}
>
{selectedView === "home" ? (
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
Expand Down Expand Up @@ -162,6 +167,7 @@ export function AppShell() {
: "No channel selected"
}
isLoading={messagesQuery.isLoading}
key={activeChannel?.id ?? "no-channel"}
messages={timelineMessages}
/>
<MessageComposer
Expand Down
90 changes: 77 additions & 13 deletions desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Paperclip, SendHorizontal, SmilePlus } from "lucide-react";
import * as React from "react";

import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";

type MessageComposerProps = {
channelName: string;
Expand All @@ -12,6 +12,8 @@ type MessageComposerProps = {
placeholder?: string;
};

const MAX_TEXTAREA_ROWS = 4;

export function MessageComposer({
channelName,
disabled = false,
Expand All @@ -20,44 +22,105 @@ export function MessageComposer({
placeholder,
}: MessageComposerProps) {
const [content, setContent] = React.useState("");
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const pendingSelectionRef = React.useRef<number | null>(null);

const submitMessage = React.useCallback(async () => {
const trimmed = content.trim();
if (!trimmed || disabled || isSending) {
return;
}

setContent("");

try {
await onSend(trimmed);
} catch {
setContent(trimmed);
}
}, [content, disabled, isSending, onSend]);

const handleSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
void submitMessage();
},
[submitMessage],
);

const trimmed = content.trim();
if (!trimmed || disabled || isSending) {
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== "Enter" || event.nativeEvent.isComposing) {
return;
}

setContent("");
if (event.ctrlKey) {
const textarea = event.currentTarget;
const { selectionEnd, selectionStart, value } = textarea;
const nextContent = `${value.slice(0, selectionStart)}\n${value.slice(selectionEnd)}`;

event.preventDefault();
pendingSelectionRef.current = selectionStart + 1;
setContent(nextContent);
return;
}

try {
await onSend(trimmed);
} catch {
setContent(trimmed);
if (event.metaKey || event.altKey || event.shiftKey) {
return;
}

event.preventDefault();
void submitMessage();
},
[content, disabled, isSending, onSend],
[submitMessage],
);

React.useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}

const lineHeight =
Number.parseFloat(window.getComputedStyle(textarea).lineHeight) || 24;
const maxHeight = lineHeight * MAX_TEXTAREA_ROWS;

textarea.style.height = "auto";
const nextHeight = Math.max(
lineHeight,
Math.min(textarea.scrollHeight, maxHeight),
);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY =
textarea.scrollHeight > maxHeight ? "auto" : "hidden";

const pendingSelection = pendingSelectionRef.current;
if (pendingSelection !== null) {
textarea.setSelectionRange(pendingSelection, pendingSelection);
pendingSelectionRef.current = null;
}
});

return (
<footer className="border-t border-border/80 bg-background p-4">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
<form
className="rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
data-testid="message-composer"
onSubmit={(event) => {
void handleSubmit(event);
handleSubmit(event);
}}
>
<Input
<Textarea
aria-label="Message channel"
className="h-auto border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
className="min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
data-testid="message-input"
disabled={disabled}
onChange={(event) => setContent(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? `Message #${channelName}`}
ref={textareaRef}
rows={1}
value={content}
/>

Expand All @@ -75,6 +138,7 @@ export function MessageComposer({
className="gap-2"
data-testid="send-message"
disabled={disabled || isSending || content.trim().length === 0}
title="Send (Enter)"
type="submit"
>
<SendHorizontal className="h-4 w-4" />
Expand Down
Loading