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
83 changes: 78 additions & 5 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3709,7 +3709,10 @@ tr.insights-row-clickable:hover {
}
.chat-thread-body {
min-width: 0;
font-size: 0.9rem;
/* 16px minimum: keeps the transcript readable and matches the composer
below, and avoids mobile Safari's zoom-on-focus threshold. */
font-size: max(var(--text-base), 16px);
font-weight: var(--font-weight-medium);
line-height: 1.55;
}
.chat-thread-error {
Expand All @@ -3725,14 +3728,84 @@ tr.insights-row-clickable:hover {
font-size: 0.82rem;
color: var(--muted-foreground);
}

/* Shared composer: transcript-matched text field on the left, a grouped
action rail (Send only — no turn cancellation exists yet) on the right. */
.chat-composer {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
}
.chat-composer > textarea {
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg, 0.75rem);
background: var(--card, var(--background));
}
.chat-composer:focus-within {
border-color: var(--ring, var(--primary));
}
.chat-composer-input {
flex: 1;
min-width: 0;
resize: none;
border: 0;
background: transparent;
padding: 0.35rem 0;
font: inherit;
font-size: max(var(--text-base), 16px);
font-weight: var(--font-weight-medium);
line-height: 1.5;
color: var(--foreground);
}
.chat-composer-input:focus {
outline: none;
}
.chat-composer-input::placeholder {
color: var(--muted-foreground);
}
.chat-composer-input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.chat-composer-rail {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
padding-bottom: 0.35rem;
}
.chat-composer-send {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
cursor: pointer;
}
.chat-composer-send:disabled {
cursor: not-allowed;
background: var(--muted);
color: var(--muted-foreground);
}
.chat-composer-send-spinner {
animation: chat-composer-spin 0.8s linear infinite;
}
@keyframes chat-composer-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.chat-composer-send-spinner {
animation-duration: 1e-6ms;
}
}

/* A workbench room: a left info column (activity, artifacts, approvals,
Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/chat/composer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// The one composer surface for a chat thread and a workbench room alike:
// Enter sends, Shift+Enter inserts a newline, and a right-hand action rail
// carries Send. There is no Stop action — neither `threads-api.ts` nor the
// hub API expose a way to cancel a running turn, so cancellation is not
// wired up here; add it to this rail once that capability exists upstream.
import { ArrowUp, CircleNotch } from "@/lib/icons";
import { useState } from "react";

export function Composer({
placeholder,
busy,
disabled,
onSend,
}: {
readonly placeholder: string;
readonly busy: boolean;
readonly disabled?: boolean;
readonly onSend: (text: string) => void;
}) {
const [text, setText] = useState("");
const canSend = text.trim() !== "" && !busy && !disabled;
const send = () => {
const trimmed = text.trim();
if (trimmed === "" || busy || disabled) return;
setText("");
onSend(trimmed);
};
return (
<div className="chat-composer">
<textarea
className="chat-composer-input"
value={text}
rows={3}
placeholder={placeholder}
aria-label={placeholder}
disabled={disabled}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
send();
}
}}
/>
<div className="chat-composer-rail">
<button
type="button"
className="chat-composer-send"
disabled={!canSend}
onClick={send}
aria-label={busy ? "Sending…" : "Send"}
title={busy ? "Sending…" : "Send"}
>
{busy ? (
<CircleNotch className="chat-composer-send-spinner" aria-hidden="true" />
) : (
<ArrowUp aria-hidden="true" />
)}
</button>
</div>
</div>
);
}
44 changes: 2 additions & 42 deletions apps/web/src/pages/chat-thread-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
// first message (agent picked in the select, or by @tagging one in the
// message itself); `/chats/:id` is the transcript plus a reply box.

import { Button, EmptyState, PageShell, Select, Textarea } from "@corbits/react-ui";
import { Button, EmptyState, PageShell, Select } from "@corbits/react-ui";
import { WarningCircle } from "@/lib/icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";

import { Composer } from "@/chat/composer";
import { Markdown } from "@/chat/markdown";
import {
agentFromMention,
Expand All @@ -26,47 +27,6 @@ function errorText(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}

function Composer({
placeholder,
busy,
disabled,
onSend,
}: {
readonly placeholder: string;
readonly busy: boolean;
readonly disabled?: boolean;
readonly onSend: (text: string) => void;
}) {
const [text, setText] = useState("");
const send = () => {
const trimmed = text.trim();
if (trimmed === "" || busy || disabled) return;
setText("");
onSend(trimmed);
};
return (
<div className="chat-composer">
<Textarea
value={text}
rows={3}
placeholder={placeholder}
aria-label={placeholder}
disabled={disabled}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
send();
}
}}
/>
<Button variant="primary" disabled={busy || disabled || text.trim() === ""} onClick={send}>
{busy ? "Sending…" : "Send"}
</Button>
</div>
);
}

function NewChat({
tenantId,
navigate,
Expand Down
47 changes: 3 additions & 44 deletions apps/web/src/pages/workbench-room-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
EmptyState,
PageShell,
Skeleton,
Textarea,
formatRelativeTime,
toast,
} from "@corbits/react-ui";
import { WarningCircle } from "@/lib/icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";

import { Composer } from "@/chat/composer";
import { Markdown } from "@/chat/markdown";
import {
agentInitials,
Expand All @@ -45,47 +45,6 @@ function errorText(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}

function RoomComposer({
placeholder,
busy,
disabled,
onSend,
}: {
readonly placeholder: string;
readonly busy: boolean;
readonly disabled?: boolean;
readonly onSend: (text: string) => void;
}) {
const [text, setText] = useState("");
const send = () => {
const trimmed = text.trim();
if (trimmed === "" || busy || disabled) return;
setText("");
onSend(trimmed);
};
return (
<div className="chat-composer">
<Textarea
value={text}
rows={3}
placeholder={placeholder}
aria-label={placeholder}
disabled={disabled}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
send();
}
}}
/>
<Button variant="primary" disabled={busy || disabled || text.trim() === ""} onClick={send}>
{busy ? "Sending…" : "Send"}
</Button>
</div>
);
}

function RoomMessageRow({
message,
participants,
Expand Down Expand Up @@ -417,7 +376,7 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) {
</div>
<div className="room-main-composer">
<PageShell width="prose" className="page-fill">
<RoomComposer
<Composer
placeholder={
startingAgent === undefined
? "Message this workbench"
Expand Down Expand Up @@ -447,7 +406,7 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) {
/>
))}
</div>
<RoomComposer
<Composer
placeholder={
startingAgent === undefined
? "Reply in this thread"
Expand Down
Loading