diff --git a/.env.example b/.env.example
index ca400ee..873e048 100644
--- a/.env.example
+++ b/.env.example
@@ -6,8 +6,8 @@ SITE_URL=http://localhost:5173
NATIVE_APP_URL=code://
# Browser and native public endpoints
+VITE_AUTH_URL=http://localhost:5173
VITE_CONVEX_URL=https://example.convex.cloud
-VITE_CONVEX_SITE_URL=https://example.convex.site
# For phone testing, replace localhost with this machine's Tailscale IPv4 address.
VITE_FLUE_URL=http://localhost:3583
EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud
diff --git a/apps/web/react-router.config.ts b/apps/web/react-router.config.ts
index 4539207..72df924 100644
--- a/apps/web/react-router.config.ts
+++ b/apps/web/react-router.config.ts
@@ -1,7 +1,7 @@
import type { Config } from "@react-router/dev/config";
export default {
- // Desktop addons package static web assets; SSR output cannot be bundled
- ssr: false,
appDirectory: "src",
+
+ ssr: true,
} satisfies Config;
diff --git a/apps/web/src/components/slice-one/slice-one-page.tsx b/apps/web/src/components/slice-one/slice-one-page.tsx
index 4388e6c..8792fe0 100644
--- a/apps/web/src/components/slice-one/slice-one-page.tsx
+++ b/apps/web/src/components/slice-one/slice-one-page.tsx
@@ -90,6 +90,30 @@ const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => {
);
};
+const ConversationLoading = () => (
+
+);
+
+const ConversationEmptyState = () => (
+
+
+
+
What should move forward?
+
+ Describe an outcome or problem. Casual conversation stays conversation.
+
+
+
+);
+
export const SliceOnePage = () => {
const slice = useSliceOne();
const viewportStyle = useVisualViewportStyle();
@@ -233,19 +257,11 @@ export const SliceOnePage = () => {
+ {!slice.agent.historyReady && timeline.length === 0 ? (
+
+ ) : null}
{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") {
diff --git a/apps/web/src/hooks/use-project-workspace.ts b/apps/web/src/hooks/use-project-workspace.ts
index 8e0c7b1..0d172c5 100644
--- a/apps/web/src/hooks/use-project-workspace.ts
+++ b/apps/web/src/hooks/use-project-workspace.ts
@@ -1,19 +1,20 @@
+import { useConvexAccessToken } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
-import { useFlueClient } from "@flue/react";
-import { useAction, useMutation, useQuery } from "convex/react";
+import { useAction, useQuery } from "convex/react";
import { useState } from "react";
import {
buildProjectLoopView,
summarizeProjectIssues,
} from "@/lib/projects/project-evidence";
+import { requestProjectIssue } from "@/lib/projects/project-requests";
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
export const useProjectWorkspace = () => {
- const flueClient = useFlueClient();
+ const resolveAccessToken = useConvexAccessToken();
const projects = useQuery(api.projects.list);
const [selectedProjectId, setSelectedProjectId] =
useState | null>(null);
@@ -25,10 +26,7 @@ export const useProjectWorkspace = () => {
const [pendingAction, setPendingAction] = useState(null);
const [error, setError] = useState(null);
const importPublicGit = useAction(api.projects.importPublicGit);
- const createIssue = useMutation(api.projectIssues.create);
- const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal);
- const beginIssue = useMutation(api.projectIssues.begin);
- const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
+
const activeProjectId =
selectedProjectId ??
(projects?.[0]?.id as unknown as Id<"projects">) ??
@@ -92,12 +90,20 @@ export const useProjectWorkspace = () => {
setPendingAction("issue");
setError(null);
try {
- const issueId = await createIssue({
- body: nextBody,
- projectId: activeProjectId,
- title: nextTitle,
+ const accessToken = await resolveAccessToken();
+ if (!accessToken) {
+ throw new Error("Authentication required");
+ }
+ const outcome = await requestProjectIssue({
+ accessToken,
+ request: {
+ body: nextBody,
+ kind: "explicit",
+ projectId: activeProjectId,
+ title: nextTitle,
+ },
});
- setSelectedIssueId(issueId);
+ setSelectedIssueId(outcome.issueId as Id<"projectIssues">);
setIssueTitle("");
setIssueBody("");
} catch (caughtError) {
@@ -111,8 +117,15 @@ export const useProjectWorkspace = () => {
setPendingAction(`signal:${signalId}`);
setError(null);
try {
- const outcome = await createIssueFromSignal({ signalId });
- setSelectedIssueId(outcome.issueId);
+ const accessToken = await resolveAccessToken();
+ if (!accessToken) {
+ throw new Error("Authentication required");
+ }
+ const outcome = await requestProjectIssue({
+ accessToken,
+ request: { kind: "signal", signalId },
+ });
+ setSelectedIssueId(outcome.issueId as Id<"projectIssues">);
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
@@ -120,23 +133,21 @@ export const useProjectWorkspace = () => {
}
};
- const startIssue = async (
- issueId: Id<"projectIssues">,
- issueNumber: number,
- title: string
- ) => {
+ const startIssue = async (issueId: Id<"projectIssues">) => {
const actionKey = `issue:${issueId}`;
setPendingAction(actionKey);
setError(null);
try {
- await beginIssue({ issueId });
- await flueClient.agents.send("project-manager", String(issueId), {
- message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`,
+ const accessToken = await resolveAccessToken();
+ if (!accessToken) {
+ throw new Error("Authentication required");
+ }
+ await requestProjectIssue({
+ accessToken,
+ request: { issueId, kind: "existing" },
});
} catch (caughtError) {
- const message = errorMessage(caughtError);
- await markDispatchFailed({ error: message, issueId });
- setError(message);
+ setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
@@ -148,7 +159,7 @@ export const useProjectWorkspace = () => {
setError("That work unit is no longer available");
return;
}
- await startIssue(issue._id, issue.number, issue.title);
+ await startIssue(issue._id);
};
const selectedProject =
projects?.find(
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 89315e1..e6f7fa7 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -75,6 +75,19 @@ html.slice-one-viewport-lock body {
min-height: 1.75rem;
}
+.route-progress-bar {
+ animation: route-progress 900ms ease-in-out infinite;
+}
+
+@keyframes route-progress {
+ from {
+ transform: translateX(-100%);
+ }
+ to {
+ transform: translateX(400%);
+ }
+}
+
@keyframes response-dot {
0%,
60%,
diff --git a/apps/web/src/lib/auth.server.ts b/apps/web/src/lib/auth.server.ts
new file mode 100644
index 0000000..c18079b
--- /dev/null
+++ b/apps/web/src/lib/auth.server.ts
@@ -0,0 +1,64 @@
+import { env } from "@code/env/web";
+import { redirect } from "react-router";
+
+interface AuthLoaderData {
+ readonly token: string | null;
+}
+
+const tokenUrl = new URL("/api/auth/convex/token", env.VITE_AUTH_URL);
+
+export const loadAuthToken = async (
+ request: Request
+): Promise => {
+ const cookie = request.headers.get("cookie");
+ if (!cookie) {
+ return { token: null };
+ }
+
+ const headers = new Headers({ cookie });
+ headers.set("host", tokenUrl.host);
+ const response = await fetch(tokenUrl, { headers });
+ if (response.status === 401) {
+ return { token: null };
+ }
+ if (!response.ok) {
+ throw new Response("Authentication service unavailable", { status: 503 });
+ }
+
+ const payload: unknown = await response.json();
+ if (
+ typeof payload !== "object" ||
+ payload === null ||
+ !("token" in payload) ||
+ typeof payload.token !== "string"
+ ) {
+ throw new Response("Authentication service returned an invalid response", {
+ status: 502,
+ });
+ }
+ return { token: payload.token };
+};
+
+export const requireAuthToken = async (
+ request: Request
+): Promise => {
+ const auth = await loadAuthToken(request);
+ if (!auth.token) {
+ const requestUrl = new URL(request.url);
+ const returnTo = `${requestUrl.pathname}${requestUrl.search}`;
+ throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
+ }
+ return auth;
+};
+
+export const redirectAuthenticated = async (
+ request: Request
+): Promise => {
+ const auth = await loadAuthToken(request);
+ if (auth.token) {
+ const requestUrl = new URL(request.url);
+ const returnTo = requestUrl.searchParams.get("returnTo");
+ throw redirect(returnTo?.startsWith("/") ? returnTo : "/");
+ }
+ return auth;
+};
diff --git a/apps/web/src/lib/projects/project-requests.ts b/apps/web/src/lib/projects/project-requests.ts
new file mode 100644
index 0000000..788d7ed
--- /dev/null
+++ b/apps/web/src/lib/projects/project-requests.ts
@@ -0,0 +1,62 @@
+import type { Id } from "@code/backend/convex/_generated/dataModel";
+import { env } from "@code/env/web";
+import { ProjectIssueRequestResult } from '@code/primitives/project-issue';
+import type { ProjectIssueRequestResult as ProjectIssueRequestResultValue } from '@code/primitives/project-issue';
+import { Schema } from "effect";
+
+const projectRequestsUrl = new URL(
+ "project-requests",
+ `${env.VITE_FLUE_URL.replace(/\/+$/u, "")}/`
+);
+
+interface RequestProjectIssueOptions {
+ readonly accessToken: string;
+ readonly request:
+ | {
+ readonly issueId: Id<"projectIssues">;
+ readonly kind: "existing";
+ }
+ | {
+ readonly body: string;
+ readonly kind: "explicit";
+ readonly projectId: Id<"projects">;
+ readonly title: string;
+ }
+ | {
+ readonly kind: "signal";
+ readonly signalId: Id<"signals">;
+ };
+}
+
+const responseError = async (response: Response): Promise => {
+ const payload: unknown = await response.json().catch(() => null);
+ if (
+ typeof payload === "object" &&
+ payload !== null &&
+ "error" in payload &&
+ typeof payload.error === "string"
+ ) {
+ return new Error(payload.error);
+ }
+ return new Error(`Project request failed (${response.status})`);
+};
+
+export const requestProjectIssue = async ({
+ accessToken,
+ request,
+}: RequestProjectIssueOptions): Promise => {
+ const response = await fetch(projectRequestsUrl, {
+ body: JSON.stringify(request),
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "content-type": "application/json",
+ },
+ method: "POST",
+ });
+ if (!response.ok) {
+ throw await responseError(response);
+ }
+ return Schema.decodeUnknownSync(ProjectIssueRequestResult)(
+ await response.json()
+ );
+};
diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx
index ee82462..91681f0 100644
--- a/apps/web/src/root.tsx
+++ b/apps/web/src/root.tsx
@@ -2,9 +2,10 @@ import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
import { env } from "@code/env/web";
import { Toaster } from "@code/ui/components/sonner";
import { FlueProvider } from "@flue/react";
+import { createFlueClient } from "@flue/sdk";
import "./index.css";
-import { createFlueClient } from "@flue/sdk";
+import { LoaderCircle } from "lucide-react";
import { useMemo } from "react";
import {
isRouteErrorResponse,
@@ -13,12 +14,16 @@ import {
Outlet,
Scripts,
ScrollRestoration,
+ useNavigation,
} from "react-router";
import type { Route } from "./+types/root";
import { ThemeProvider } from "./components/theme-provider";
+import { loadAuthToken } from "./lib/auth.server";
import { createFlueFetch } from "./lib/flue-transport";
+export const loader = ({ request }: Route.LoaderArgs) => loadAuthToken(request);
+
export const links: Route.LinksFunction = () => [
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
{
@@ -79,8 +84,31 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (