diff --git a/.env.example b/.env.example index c27f1b5..ca400ee 100644 --- a/.env.example +++ b/.env.example @@ -25,12 +25,12 @@ DAEMON_COMMAND_LEASE_MS=60000 FLUE_DB_TOKEN=replace-with-a-long-random-token # Agent model provider -AGENT_MODEL_PROVIDER=cheaptricks -AGENT_MODEL_NAME=glm-5.2 +AGENT_MODEL_PROVIDER=xiaomi +AGENT_MODEL_NAME=mimo-v2.5 AGENT_MODEL_API=openai-completions AGENT_MODEL_BASE_URL=https://ai.example.com/v1 AGENT_MODEL_API_KEY=replace-with-provider-api-key -AGENT_MODEL_CONTEXT_WINDOW=262000 +AGENT_MODEL_CONTEXT_WINDOW=1048576 AGENT_MODEL_MAX_TOKENS=131072 # Self-hosted git (Gitea) — canonical repository host and API token diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 8c2852d..5470e7a 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -18,7 +18,7 @@ "@rivet-dev/agentos": "catalog:", "convex": "catalog:", "effect": "catalog:", - "rivetkit": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0" + "rivetkit": "2.3.7" }, "devDependencies": { "@code/config": "workspace:*", diff --git a/apps/daemon/src/runtime.ts b/apps/daemon/src/runtime.ts index 8128e64..acf8e44 100644 --- a/apps/daemon/src/runtime.ts +++ b/apps/daemon/src/runtime.ts @@ -2,26 +2,27 @@ import { env } from "@code/env/server"; import { Console, Effect, FiberSet, Result, Stream } from "effect"; import { makeAgentOsRuntime } from "./agent-os"; -import { ConvexControlPlane, type DaemonCommand } from "./convex"; +import { ConvexControlPlane } from "./convex"; +import type { DaemonCommand } from "./convex"; const errorMessage = (cause: unknown) => cause instanceof Error ? cause.message : String(cause); export const runDaemon = Effect.scoped( - Effect.gen(function* () { + Effect.gen(function* runDaemon() { const controlPlane = yield* ConvexControlPlane; const agentOs = yield* makeAgentOsRuntime; const sessionId = crypto.randomUUID(); const hostname = yield* Effect.promise(() => import("node:os")).pipe( Effect.map((os) => os.hostname()) ); - const fibers = yield* FiberSet.make(); + const fibers = yield* FiberSet.make(); yield* controlPlane.connect({ - sessionId, + architecture: process.arch, hostname, platform: process.platform, - architecture: process.arch, + sessionId, }); yield* Console.log(`daemon ${env.DAEMON_ID} connected as ${sessionId}`); @@ -40,33 +41,37 @@ export const runDaemon = Effect.scoped( Effect.forkScoped ); - const processCommand = Effect.fn("Daemon.processCommand")(function* ( - candidate: DaemonCommand - ) { - const command = yield* controlPlane.claim(candidate._id, sessionId); - if (!command) { - return; + const processCommand = Effect.fn("Daemon.processCommand")( + function* processCommand(candidate: DaemonCommand) { + const command = yield* controlPlane.claim(candidate._id, sessionId); + if (!command) { + return; + } + const started = yield* controlPlane.start(command._id, sessionId); + if (!started) { + return; + } + yield* controlPlane.recordEvent({ + commandId: command._id, + data: { actorKey: command.actorKey, method: command.method }, + kind: "command.started", + }); + const result = yield* agentOs.execute(command).pipe(Effect.result); + if (Result.isSuccess(result)) { + yield* controlPlane.succeed( + command._id, + sessionId, + result.success ?? null + ); + return; + } + yield* controlPlane.fail( + command._id, + sessionId, + errorMessage(result.failure) + ); } - const started = yield* controlPlane.start(command._id, sessionId); - if (!started) { - return; - } - yield* controlPlane.recordEvent({ - commandId: command._id, - kind: "command.started", - data: { method: command.method, actorKey: command.actorKey }, - }); - const result = yield* agentOs.execute(command).pipe(Effect.result); - if (Result.isSuccess(result)) { - yield* controlPlane.succeed(command._id, sessionId, result.success); - return; - } - yield* controlPlane.fail( - command._id, - sessionId, - errorMessage(result.failure) - ); - }); + ); yield* controlPlane.commands.pipe( Stream.runForEach((commands) => diff --git a/apps/web/package.json b/apps/web/package.json index bc779f4..18306c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@code/env": "workspace:*", "@code/primitives": "workspace:*", "@code/ui": "workspace:*", + "@code/work-os": "workspace:*", "@flue/react": "1.0.0-beta.9", "@flue/sdk": "1.0.0-beta.9", "@react-router/fs-routes": "^8.1.0", diff --git a/apps/web/src/components/chat/chat-attachments.tsx b/apps/web/src/components/chat/chat-attachments.tsx new file mode 100644 index 0000000..df8b2a0 --- /dev/null +++ b/apps/web/src/components/chat/chat-attachments.tsx @@ -0,0 +1,162 @@ +import { useConvexAccessToken } from "@code/auth/web"; +import { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, +} from "@code/ui/components/attachment"; +import type { FlueConversationPart } from "@flue/react"; +import { ImageIcon, X } from "lucide-react"; +import { useEffect, useState } from "react"; + +import type { PendingChatImage } from "@/lib/chat/attachments"; + +type FilePart = Extract; + +const isDirectlyRenderableUrl = (url: string): boolean => + url.startsWith("blob:") || url.startsWith("data:"); + +const useAuthenticatedImageSource = (url?: string): string | undefined => { + const resolveAccessToken = useConvexAccessToken(); + const [loaded, setLoaded] = useState<{ + readonly source: string; + readonly url: string; + }>(); + + useEffect(() => { + if (!url || isDirectlyRenderableUrl(url)) { + return; + } + let active = true; + let objectUrl: string | undefined; + const load = async (): Promise => { + try { + const accessToken = await resolveAccessToken(); + const response = await fetch(url, { + headers: accessToken + ? { authorization: `Bearer ${accessToken}` } + : undefined, + }); + if (!response.ok) { + return; + } + objectUrl = URL.createObjectURL(await response.blob()); + if (active) { + setLoaded({ source: objectUrl, url }); + } + } catch { + // The attachment remains represented by its filename and media type. + } + }; + void load(); + return () => { + active = false; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [resolveAccessToken, url]); + + if (url && isDirectlyRenderableUrl(url)) { + return url; + } + return loaded && loaded.url === url ? loaded.source : undefined; +}; + +const ImagePreview = ({ + alt, + mediaType, + url, +}: { + readonly alt: string; + readonly mediaType: string; + readonly url?: string; +}) => { + const source = useAuthenticatedImageSource(url); + return ( + + {source && mediaType.startsWith("image/") ? ( + {alt} + ) : ( + + )} + + ); +}; + +export const PendingChatAttachments = ({ + images, + onRemove, +}: { + readonly images: readonly PendingChatImage[]; + readonly onRemove: (id: string) => void; +}) => ( + + {images.map((image) => ( + + + + {image.file.name} + + + onRemove(image.id)} + > + + + + + ))} + +); + +export const MessageAttachments = ({ + parts, +}: { + readonly parts: readonly FilePart[]; +}) => { + if (parts.length === 0) { + return null; + } + return ( + + {parts.map((part, index) => { + const title = part.filename ?? `Image ${index + 1}`; + return ( + + + + {title} + {part.mediaType} + + + ); + })} + + ); +}; diff --git a/apps/web/src/components/chat/chat-message.tsx b/apps/web/src/components/chat/chat-message.tsx index 36e0ecf..e487e69 100644 --- a/apps/web/src/components/chat/chat-message.tsx +++ b/apps/web/src/components/chat/chat-message.tsx @@ -7,26 +7,63 @@ import { MobileChatBubble, MobileChatMessage, } from "@code/ui/components/mobile-chat"; -import type { ReactNode } from "react"; +import type { FlueConversationMessage } from "@flue/react"; -import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms"; -import type { ChatMessageProps } from "@/lib/chat/types"; +import { + getMessageText, + getReasoningText, + hasToolActivity, + isReasoningStreaming, + isMessageStreaming, +} from "@/lib/chat/transforms"; +import type { + AssistantResponseState, + ChatMessageProps, +} from "@/lib/chat/types"; import { AssistantIdentity } from "./assistant-identity"; +import { MessageAttachments } from "./chat-attachments"; import { ChatToolCall } from "./chat-tool-call"; -export const ChatMessage = ({ message }: ChatMessageProps) => { - const isUser = message.role === "user"; - const isStreaming = isMessageStreaming(message); - const text = getMessageText(message); - - if (message.parts.length === 0 && !isStreaming) { +const ReasoningTrace = ({ + isStreaming, + text, +}: { + readonly isStreaming: boolean; + readonly text: string; +}) => { + if (!text) { return null; } + return ( +
+ + {isStreaming ? "Thinking live" : "Thinking trace"} + + + {text} + +
+ ); +}; - let content: ReactNode = text; - if (!isUser) { - content = text ? ( +const AssistantText = ({ + hasReasoning, + isStreaming, + text, +}: { + readonly hasReasoning: boolean; + readonly isStreaming: boolean; + readonly text: string; +}) => { + if (text) { + return ( { > {text} - ) : ( -
- - - -
); } + if (hasReasoning) { + return null; + } + return ( +
+ + + +
+ ); +}; + +const assistantState = ( + isStreaming: boolean, + reasoningStreaming: boolean, + text: string +): AssistantResponseState | undefined => { + if (reasoningStreaming && !text) { + return "thinking"; + } + return isStreaming ? "writing" : undefined; +}; + +const ToolActivity = ({ + hidden, + message, +}: { + readonly hidden: boolean; + readonly message: FlueConversationMessage; +}) => { + if (hidden) { + return null; + } + return message.parts.map((part) => + part.type === "dynamic-tool" ? ( + + ) : null + ); +}; + +export const ChatMessage = ({ + hideToolActivity = false, + message, +}: ChatMessageProps) => { + const isUser = message.role === "user"; + const isStreaming = isMessageStreaming(message); + const text = getMessageText(message); + const reasoning = getReasoningText(message); + const reasoningStreaming = isReasoningStreaming(message); + const fileParts = message.parts.filter((part) => part.type === "file"); + + if ( + hideToolActivity && + !isUser && + hasToolActivity(message) && + !text && + !reasoning + ) { + return null; + } + + if (message.parts.length === 0 && !isStreaming) { + return null; + } const sender = isUser ? "user" : "assistant"; + const content = isUser ? ( + text + ) : ( + + ); + const identityState = assistantState(isStreaming, reasoningStreaming, text); return (
- {!isUser && ( - - )} + {isUser ? null : } - {message.parts.map((part) => - part.type === "dynamic-tool" ? ( - - ) : null + + {isUser ? null : ( + )} +
diff --git a/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx b/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx index 769603a..11fb163 100644 --- a/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx +++ b/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx @@ -1,19 +1,29 @@ +import { signOutWeb, useWebAuth } from "@code/auth/web"; import type { Id } from "@code/backend/convex/_generated/dataModel"; import { MobileAssistantChatScreen, MobileExpandedWorkScreen, MobileHomeScreen, + MobileWorkChatScreen, MobileWorkListScreen, + MobileWorkStackScreen, MobileWorkUnitDetailScreen, } from "@code/ui/components/mobile-product"; +import type { FormEvent } from "react"; import { useNavigate, useParams } from "react-router"; import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace"; +import { useMobileWorkStack } from "@/hooks/use-mobile-work-stack"; import { useMobileWorkspace } from "@/hooks/use-mobile-workspace"; +import { MODEL_ID, MODEL_LABEL } from "@/lib/chat/constants"; +import { getMobileStatusMessage } from "@/lib/mobile-workspace/mobile-status-message"; +import { getUserInitials } from "@/lib/users/get-user-initials"; export type MobileFlowScreen = | "assistant-chat" | "home" + | "stack-home" + | "work-chat" | "work-list" | "work-unit-detail"; @@ -24,6 +34,7 @@ interface MobileFlowPageProps { export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { const navigate = useNavigate(); const { workUnitId } = useParams(); + const auth = useWebAuth(); const projectWorkspace = useMobileProjectWorkspace({ selectedWorkUnitId: workUnitId, }); @@ -32,21 +43,36 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { projectWorkspace.raiseIssue({ body, title }), onSend: projectWorkspace.sendMessage, }); + const workStack = useMobileWorkStack(projectWorkspace.data.workUnits); const workPath = "/work"; const chatPath = "/chat"; + const handleRestoreChecked = workStack.restoreChecked; + const handleWorkUnitChecked = workStack.markChecked; + const handleWorkUnitSentBack = workStack.sendToBack; + const user = auth.status === "authenticated" ? auth.user : undefined; + const handleSignOut = async () => { + await signOutWeb(); + navigate("/login"); + }; + const statusMessage = getMobileStatusMessage({ + assistantError: projectWorkspace.error?.message, + localStatus: workspace.statusMessage, + }); + const handleRepositoryChange = + projectWorkspace.projectWorkspace.setRepository; const handleOpenUnit = (selectedWorkUnitId?: string) => { const targetId = selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id; if (!targetId) { - navigate("/dashboard"); + workspace.setProjectManagerOpen(true); return; } if (screen === "work-list" && !workspace.expanded) { workspace.setExpanded(true); return; } - navigate(`/work/${targetId}`); + navigate(`/chat/${targetId}`); }; const screenProps = { @@ -54,8 +80,11 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { createIssueBody: workspace.issueBody, createIssueTitle: workspace.issueTitle, data: projectWorkspace.data, - onBack: () => navigate(workPath), + modelId: MODEL_ID, + modelLabel: MODEL_LABEL, + onBack: () => navigate(chatPath), onComposerChange: workspace.handleComposerChange, + onComposerMessageSubmit: workspace.handleComposerMessageSubmit, onComposerSubmit: workspace.handleComposerSubmit, onCreateBodyChange: workspace.setIssueBody, onCreateIssue: workspace.handleCreateIssueSubmit, @@ -66,16 +95,24 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { ) : undefined, onCreateTitleChange: workspace.setIssueTitle, - onManageProjects: () => navigate("/dashboard"), + onManageProjects: () => workspace.setProjectManagerOpen(true), onOpenAssistant: () => navigate(chatPath), onOpenUnit: handleOpenUnit, + onProjectManagerClose: () => workspace.setProjectManagerOpen(false), onProjectSelect: (projectId: string) => projectWorkspace.projectWorkspace.setSelectedProjectId( projectId as Id<"projects"> ), + onRepositoryChange: projectWorkspace.projectWorkspace.setRepository, + onRepositoryConnect: (event: FormEvent) => { + event.preventDefault(); + void projectWorkspace.projectWorkspace.connectRepository(); + }, onReviewUnit: (reviewUrl: string) => { window.open(reviewUrl, "_blank", "noopener,noreferrer"); }, + onSettingsClose: () => workspace.setSettingsOpen(false), + onSettingsOpen: () => workspace.setSettingsOpen(true), onStartUnit: (selectedIssueId: string) => { void projectWorkspace.startWorkUnit( selectedIssueId as Id<"projectIssues"> @@ -83,12 +120,73 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { }, onViewWork: () => navigate(workPath), pendingAction: projectWorkspace.projectWorkspace.pendingAction, - statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message, + projectManagerOpen: workspace.projectManagerOpen, + repositoryValue: projectWorkspace.projectWorkspace.repository, + settingsOpen: workspace.settingsOpen, + statusMessage, }; if (screen === "assistant-chat") { return ; } + if (screen === "stack-home") { + return ( + workspace.setProjectManagerOpen(true)} + onComposerChange={workspace.handleComposerChange} + onComposerSubmit={workspace.handleComposerSubmit} + onOpenUnit={(selectedWorkUnitId) => + navigate(`/chat/${selectedWorkUnitId}`) + } + onProjectSelect={(projectId) => + projectWorkspace.projectWorkspace.setSelectedProjectId( + projectId as Id<"projects"> + ) + } + onProjectManagerClose={() => workspace.setProjectManagerOpen(false)} + onRepositoryChange={handleRepositoryChange} + onRepositoryConnect={(event) => { + event.preventDefault(); + void projectWorkspace.projectWorkspace.connectRepository(); + }} + onRestoreChecked={handleRestoreChecked} + onSettingsClose={() => workspace.setSettingsOpen(false)} + onSettingsOpen={() => workspace.setSettingsOpen(true)} + onSignOut={() => { + void handleSignOut(); + }} + onWorkUnitChecked={handleWorkUnitChecked} + onWorkUnitSentBack={handleWorkUnitSentBack} + pendingAction={projectWorkspace.projectWorkspace.pendingAction} + projectManagerOpen={workspace.projectManagerOpen} + repositoryValue={projectWorkspace.projectWorkspace.repository} + settingsOpen={workspace.settingsOpen} + statusMessage={statusMessage} + userEmail={user?.email} + userInitials={getUserInitials(user?.name)} + userName={user?.name} + workUnits={workStack.visibleWorkUnits} + /> + ); + } + if (screen === "work-chat") { + return ( + navigate(chatPath)} + onComposerChange={workspace.handleComposerChange} + onComposerMessageSubmit={workspace.handleComposerMessageSubmit} + onComposerSubmit={workspace.handleComposerSubmit} + statusMessage={statusMessage} + /> + ); + } if (screen === "home") { return ; } diff --git a/apps/web/src/components/slice-one/slice-one-page.tsx b/apps/web/src/components/slice-one/slice-one-page.tsx new file mode 100644 index 0000000..928390e --- /dev/null +++ b/apps/web/src/components/slice-one/slice-one-page.tsx @@ -0,0 +1,416 @@ +import { + Conversation, + ConversationContent, +} from "@code/ui/components/ai-elements/conversation"; +import { Button } from "@code/ui/components/button"; +import { projectWorkNotices } from "@code/work-os"; +import { + ChevronRight, + FolderGit2, + LoaderCircle, + Menu, + MessageSquareText, + ImagePlus, + Send, + Sparkles, + X, +} from "lucide-react"; +import { useMemo, useRef, useState } from "react"; + +import { PendingChatAttachments } from "@/components/chat/chat-attachments"; +import { ChatMessage } from "@/components/chat/chat-message"; +import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response"; +import { useChatImages } from "@/hooks/chat/use-chat-images"; +import { useSliceOne } from "@/hooks/slice-one/use-slice-one"; +import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport"; +import { chatImageToPromptImage } from "@/lib/chat/attachments"; +import { + buildSliceOneTimeline, + findSourceMessageTarget, +} from "@/lib/slice-one/presentation"; + +type SliceWork = NonNullable["works"]>[number]; +const EMPTY_WORKS: readonly SliceWork[] = []; + +interface WorkCardProps { + readonly onSourceSelect: (rawText: string) => void; + readonly work: SliceWork; +} + +const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => { + const [sourcesOpen, setSourcesOpen] = useState(false); + const sources = work.signals.flatMap((signal) => signal.sources); + return ( +
+
+ + + +
+

+ Proposed Work +

+

+ {work.title} +

+

+ {work.objective} +

+
+
+ + {sourcesOpen ? ( +
+ {sources.map((source) => ( + + ))} +
+ ) : null} +
+ ); +}; + +export const SliceOnePage = () => { + const slice = useSliceOne(); + const viewportStyle = useVisualViewportStyle(); + const [draft, setDraft] = useState(""); + const attachments = useChatImages(); + const imageInput = useRef(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [highlightedMessageId, setHighlightedMessageId] = useState(); + const highlightTimer = useRef | null>(null); + const works = slice.works ?? EMPTY_WORKS; + const workById = useMemo( + () => new Map(works.map((work) => [String(work._id), work])), + [works] + ); + const notices = useMemo(() => projectWorkNotices(works), [works]); + const timeline = useMemo( + () => buildSliceOneTimeline(slice.agent.messages, notices), + [notices, slice.agent.messages] + ); + const busy = + slice.agent.status === "submitted" || slice.agent.status === "streaming"; + + const revealSourceMessage = (rawText: string) => { + const messageId = findSourceMessageTarget(slice.agent.messages, rawText); + if (!messageId) { + return; + } + setDrawerOpen(false); + setHighlightedMessageId(messageId); + if (highlightTimer.current) { + clearTimeout(highlightTimer.current); + } + requestAnimationFrame(() => { + document + .querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + highlightTimer.current = setTimeout( + () => setHighlightedMessageId(undefined), + 1800 + ); + }; + + if (slice.projects === undefined) { + return ( +
+ +
+ ); + } + + if (!slice.selectedProject) { + return ( +
+
{ + event.preventDefault(); + void slice.connectRepository(); + }} + > + + + +

Connect one project

+

+ Slice 1 turns actionable conversation into proposed Work with exact + provenance. +

+ slice.setRepository(event.target.value)} + placeholder="https://github.com/owner/repository" + required + value={slice.repository} + /> + {slice.error ? ( +

{slice.error.message}

+ ) : null} + +
+
+ ); + } + + const send = async () => { + const message = draft.trim(); + if (!message || busy) { + return; + } + const images = await Promise.all( + attachments.images.map(chatImageToPromptImage) + ); + await slice.agent.sendMessage(message, { images }); + setDraft(""); + attachments.clear(); + }; + + return ( +
+
+
+
+ +

+ Conversation to proposed Work +

+
+ +
+ + + {slice.agent.historyReady && timeline.length === 0 ? ( +
+
+ +

+ What should move forward? +

+

+ Describe an outcome or problem. Casual conversation stays + conversation. +

+
+
+ ) : null} + {timeline.map((item) => { + if (item.kind === "work") { + const work = workById.get(item.notice.workId); + return work ? ( +
+

+ Work proposed from this conversation +

+ +
+ ) : null; + } + return ( +
+ +
+ ); + })} + {slice.agent.status === "submitted" ? ( + + ) : null} +
+
+
+ {attachments.images.length > 0 ? ( +
+ +
+ ) : null} + {slice.agent.error ? ( +

+ {slice.agent.error.message} +

+ ) : null} + {attachments.error ? ( +

+ {attachments.error} +

+ ) : null} +
+ { + attachments.addFiles(event.target.files); + event.target.value = ""; + }} + ref={imageInput} + type="file" + /> + +