diff --git a/apps/web/src/components/work-os/conversation-panel.tsx b/apps/web/src/components/work-os/conversation-panel.tsx new file mode 100644 index 0000000..84cd8fb --- /dev/null +++ b/apps/web/src/components/work-os/conversation-panel.tsx @@ -0,0 +1,53 @@ +import { + Conversation, + ConversationContent, +} from "@code/ui/components/ai-elements/conversation"; +import { LoaderCircle, MessagesSquare } from "lucide-react"; + +import { ChatMessage } from "@/components/chat/chat-message"; +import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response"; +import type { ChatAgentState } from "@/lib/chat/types"; + +interface ConversationPanelProps { + readonly agent: ChatAgentState; + readonly title: string; + readonly emptyHint: string; +} + +export const ConversationPanel = ({ + agent, + emptyHint, + title, +}: ConversationPanelProps) => { + if (agent.status === "connecting" && !agent.historyReady) { + return ( +
+ + Loading conversation… +
+ ); + } + + if (agent.historyReady && agent.messages.length === 0) { + return ( +
+ +

{title}

+

+ {emptyHint} +

+
+ ); + } + + return ( + + + {agent.messages.map((message) => ( + + ))} + {agent.status === "submitted" ? : null} + + + ); +}; diff --git a/apps/web/src/components/work-os/empty-state.tsx b/apps/web/src/components/work-os/empty-state.tsx new file mode 100644 index 0000000..fce8fbb --- /dev/null +++ b/apps/web/src/components/work-os/empty-state.tsx @@ -0,0 +1,54 @@ +import { Bot, FolderGit2 } from "lucide-react"; + +interface EmptyStateProps { + readonly onConnectRepository: () => void; + readonly busy: boolean; + readonly repository: string; + readonly onRepositoryChange: (value: string) => void; +} + +export const EmptyState = ({ + busy, + onConnectRepository, + onRepositoryChange, + repository, +}: EmptyStateProps) => ( +
+
+
+ +
+

+ Connect a project to begin +

+

+ Import a repository to start the Work OS loop. Turn a clear outcome into + a Work Unit that Zopu can start, verify, and review. +

+
{ + event.preventDefault(); + onConnectRepository(); + }} + > + onRepositoryChange(event.target.value)} + placeholder="https://github.com/owner/repo" + required + value={repository} + /> + +
+
+
+); diff --git a/apps/web/src/components/work-os/signals-panel.tsx b/apps/web/src/components/work-os/signals-panel.tsx new file mode 100644 index 0000000..a3ddfd7 --- /dev/null +++ b/apps/web/src/components/work-os/signals-panel.tsx @@ -0,0 +1,71 @@ +import { Radio } from "lucide-react"; + +import type { SignalItem } from "@/hooks/work-os/use-work-os"; +import { formatRelativeTime } from "@/lib/work-os/work-unit-projection"; + +interface SignalsPanelProps { + readonly signals: readonly SignalItem[]; + readonly loading: boolean; +} + +export const SignalsPanel = ({ loading, signals }: SignalsPanelProps) => { + if (loading) { + return ( +
+
+ + Signals +
+

Loading signals…

+
+ ); + } + + if (signals.length === 0) { + return ( +
+
+ + Signals +
+

+ No Signals detected for this project yet. +

+
+ ); + } + + return ( +
+
+
+ + Signals +
+ + {signals.length} total + +
+
+ {signals.map((signal) => ( +
+

{signal.title}

+

+ {signal.summary} +

+
+ + {signal.sourceCount} source + {signal.sourceCount === 1 ? "" : "s"} + + {formatRelativeTime(signal.createdAt)} +
+
+ ))} +
+
+ ); +}; diff --git a/apps/web/src/components/work-os/work-os-composer.tsx b/apps/web/src/components/work-os/work-os-composer.tsx new file mode 100644 index 0000000..0096792 --- /dev/null +++ b/apps/web/src/components/work-os/work-os-composer.tsx @@ -0,0 +1,143 @@ +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; + 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(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) => { + if ( + event.key === "Enter" && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault(); + void handleSubmit(); + } + }; + + const scopeLabel = + mode === "project" ? "Project" : (workUnitTitle ?? "Work Unit"); + + return ( +
+ {/* Mode switcher */} +
+
+ + +
+ {mode === "work-unit" && workUnitTitle ? ( + + onModeChange("project")} + /> + {workUnitTitle} + + ) : null} + + {scopeLabel} + +
+ + {error ? ( +

+ {error.message || "Message failed to send"} +

+ ) : null} + +
+