- Archive dormant apps (daemon, desktop, native, tui) and superseded packages/server into repos/ for reference - Archive 21 dead web components (chat page shell, work-os cluster, strays) into repos/web/; keep reused message-rendering primitives in components/chat - Merge @code/work-os into @code/primitives; work.ts absorbed via ./work subpath and barrel export; update all four importers - Add docs/futures.md tracking audio/video (blocked at Flue + provider), web layout cleanup, and message rendering quality - Repoint dev:zopu script to @code/agents (the live Flue server) - Note repos/ reference-code convention in AGENTS.md
144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
import { Button } from "@code/ui/components/button";
|
|
import { LoaderCircle, Send, X } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
import type { ComposerMode } from "@/hooks/work-os/use-work-os";
|
|
|
|
interface WorkOsComposerProps {
|
|
readonly mode: ComposerMode;
|
|
readonly onModeChange: (mode: ComposerMode) => void;
|
|
readonly onSend: (message: string) => Promise<void>;
|
|
readonly busy: boolean;
|
|
readonly error?: Error;
|
|
readonly workUnitTitle?: string;
|
|
readonly placeholder?: string;
|
|
}
|
|
|
|
export const WorkOsComposer = ({
|
|
busy,
|
|
error,
|
|
mode,
|
|
onModeChange,
|
|
onSend,
|
|
placeholder,
|
|
workUnitTitle,
|
|
}: WorkOsComposerProps) => {
|
|
const [draft, setDraft] = useState("");
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const canSend = draft.trim().length > 0 && !busy;
|
|
|
|
useEffect(() => {
|
|
if (!busy) {
|
|
textareaRef.current?.focus();
|
|
}
|
|
}, [busy, mode]);
|
|
|
|
const handleSubmit = async () => {
|
|
const message = draft.trim();
|
|
if (!message || busy) {
|
|
return;
|
|
}
|
|
setDraft("");
|
|
try {
|
|
await onSend(message);
|
|
} finally {
|
|
textareaRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (
|
|
event.key === "Enter" &&
|
|
!event.shiftKey &&
|
|
!event.nativeEvent.isComposing
|
|
) {
|
|
event.preventDefault();
|
|
void handleSubmit();
|
|
}
|
|
};
|
|
|
|
const scopeLabel =
|
|
mode === "project" ? "Project" : (workUnitTitle ?? "Work Unit");
|
|
|
|
return (
|
|
<div className="border-t border-border/40 bg-background/80 px-4 py-3 backdrop-blur-xl sm:px-6">
|
|
{/* Mode switcher */}
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<div className="flex items-center gap-0.5 rounded-lg bg-muted/60 p-0.5">
|
|
<button
|
|
className={`rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
|
mode === "project"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
onClick={() => onModeChange("project")}
|
|
type="button"
|
|
>
|
|
Project
|
|
</button>
|
|
<button
|
|
className={`flex items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
|
mode === "work-unit"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
disabled={!workUnitTitle}
|
|
onClick={() => onModeChange("work-unit")}
|
|
type="button"
|
|
>
|
|
Work Unit
|
|
</button>
|
|
</div>
|
|
{mode === "work-unit" && workUnitTitle ? (
|
|
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
|
<X
|
|
className="size-3 cursor-pointer"
|
|
onClick={() => onModeChange("project")}
|
|
/>
|
|
<span className="max-w-40 truncate">{workUnitTitle}</span>
|
|
</span>
|
|
) : null}
|
|
<span className="ml-auto text-[10px] text-muted-foreground/50">
|
|
{scopeLabel}
|
|
</span>
|
|
</div>
|
|
|
|
{error ? (
|
|
<p className="mb-2 text-[11px] text-destructive" id="composer-error">
|
|
{error.message || "Message failed to send"}
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="flex items-end gap-2.5">
|
|
<textarea
|
|
aria-describedby={error ? "composer-error" : undefined}
|
|
aria-invalid={error ? true : undefined}
|
|
aria-label={`Message ${scopeLabel}`}
|
|
className="min-h-11 max-h-32 flex-1 resize-none rounded-2xl border border-border/40 bg-card/40 px-4 py-2.5 text-sm leading-5 outline-none transition-colors placeholder:text-muted-foreground/60 focus-visible:border-foreground/20 disabled:cursor-not-allowed disabled:opacity-60"
|
|
disabled={busy}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder ?? `Message ${scopeLabel}…`}
|
|
ref={textareaRef}
|
|
rows={1}
|
|
value={draft}
|
|
/>
|
|
<Button
|
|
aria-label="Send message"
|
|
className="size-11 shrink-0 rounded-full"
|
|
disabled={!canSend}
|
|
onClick={() => void handleSubmit()}
|
|
size="icon"
|
|
type="button"
|
|
>
|
|
{busy ? (
|
|
<LoaderCircle className="size-4 animate-spin" />
|
|
) : (
|
|
<Send className="size-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|