diff --git a/apps/web/package.json b/apps/web/package.json
index 9a0d145..1196287 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -25,8 +25,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "^8.1.0",
- "sonner": "catalog:",
- "streamdown": "catalog:"
+ "sonner": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
diff --git a/apps/web/src/components/projects/add-project-panel.tsx b/apps/web/src/components/projects/add-project-panel.tsx
index cd81656..feeafa5 100644
--- a/apps/web/src/components/projects/add-project-panel.tsx
+++ b/apps/web/src/components/projects/add-project-panel.tsx
@@ -1,6 +1,9 @@
+import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
-import { X } from "lucide-react";
-import { useEffect, useRef } from "react";
+import { Button } from "@code/ui/components/button";
+import { useAction } from "convex/react";
+import { Globe2, LoaderCircle, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ProviderChips } from "./provider-chips";
@@ -19,6 +22,10 @@ export const AddProjectPanel = ({
readonly onProjectCreated: (projectId: string) => void;
}) => {
const dialogRef = useRef(null);
+ const importPublicGit = useAction(api.projects.importPublicGit);
+ const [repositoryUrl, setRepositoryUrl] = useState("");
+ const [importing, setImporting] = useState(false);
+ const [importError, setImportError] = useState();
const githubConnectionId = accounts?.find(
(account) => account.provider === "github" && account.status === "active"
@@ -30,6 +37,29 @@ export const AddProjectPanel = ({
return () => dialog?.close();
}, []);
+ const importPublicRepository = async (event: React.FormEvent) => {
+ event.preventDefault();
+ const url = repositoryUrl.trim();
+ if (!url || importing) {
+ return;
+ }
+ setImporting(true);
+ setImportError(undefined);
+ try {
+ const project = await importPublicGit({ repositoryUrl: url });
+ setRepositoryUrl("");
+ onProjectCreated(String(project.id));
+ } catch (caughtError) {
+ setImportError(
+ caughtError instanceof Error
+ ? caughtError.message
+ : "Public Git import failed"
+ );
+ } finally {
+ setImporting(false);
+ }
+ };
+
return createPortal(
+
+
{
setPending(true);
try {
await authClient.linkSocial({
- callbackURL: `${window.location.origin}/projects?resume=github`,
+ callbackURL: `${window.location.origin}/?resume=github`,
provider: "github",
});
} finally {
diff --git a/apps/web/src/components/projects/github-connection-settings.tsx b/apps/web/src/components/projects/github-connection-settings.tsx
index 680fe2f..5178f4d 100644
--- a/apps/web/src/components/projects/github-connection-settings.tsx
+++ b/apps/web/src/components/projects/github-connection-settings.tsx
@@ -67,7 +67,7 @@ export const GithubConnectionSettings = ({
setReauthorizing(true);
try {
await authClient.linkSocial({
- callbackURL: `${window.location.origin}/projects?resume=github`,
+ callbackURL: `${window.location.origin}/?resume=github`,
provider: "github",
// Pass the full desired scope set so GitHub grants everything.
scopes: [...GITHUB_EXPANDED_SCOPES],
diff --git a/apps/web/src/components/projects/projects-grid.tsx b/apps/web/src/components/projects/projects-grid.tsx
index f6ef448..00393aa 100644
--- a/apps/web/src/components/projects/projects-grid.tsx
+++ b/apps/web/src/components/projects/projects-grid.tsx
@@ -29,7 +29,7 @@ export const ProjectsGrid = ({
- Choose a repository to continue its project conversation.
+ Open a project to manage its repository context.
diff --git a/apps/web/src/components/projects/projects-header.tsx b/apps/web/src/components/projects/projects-header.tsx
index 7585df7..dcb288c 100644
--- a/apps/web/src/components/projects/projects-header.tsx
+++ b/apps/web/src/components/projects/projects-header.tsx
@@ -1,13 +1,9 @@
import { Button } from "@code/ui/components/button";
-import { ArrowLeft, FolderGit2, Plus } from "lucide-react";
+import { FolderGit2, Plus } from "lucide-react";
export const ProjectsHeader = ({
- hasProjects,
- onGoHome,
onOpenAddProject,
}: {
- readonly hasProjects: boolean;
- readonly onGoHome: () => void;
readonly onOpenAddProject: () => void;
}) => (
@@ -17,35 +13,21 @@ export const ProjectsHeader = ({
- Zopu workspace
+ Zopu
Projects
-
- {hasProjects ? (
-
-
- Workspace
-
- ) : null}
-
-
- Add project
-
-
+
+
+ Add project
+
);
diff --git a/apps/web/src/components/projects/provider-chips.tsx b/apps/web/src/components/projects/provider-chips.tsx
index 86c4119..c86e016 100644
--- a/apps/web/src/components/projects/provider-chips.tsx
+++ b/apps/web/src/components/projects/provider-chips.tsx
@@ -106,7 +106,7 @@ export const ProviderChips = ({ accounts }: ProviderChipsProps) => {
setPendingProvider("github");
try {
await authClient.linkSocial({
- callbackURL: `${window.location.origin}/projects?resume=github`,
+ callbackURL: `${window.location.origin}/?resume=github`,
provider: "github",
});
} finally {
diff --git a/apps/web/src/components/projects/repository-selector.tsx b/apps/web/src/components/projects/repository-selector.tsx
index b2642f9..42e6912 100644
--- a/apps/web/src/components/projects/repository-selector.tsx
+++ b/apps/web/src/components/projects/repository-selector.tsx
@@ -3,7 +3,6 @@ import { Button } from "@code/ui/components/button";
import { useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import {
- ArrowUpRight,
LoaderCircle,
FolderGit2,
GitBranch,
@@ -13,7 +12,6 @@ import {
Server,
} from "lucide-react";
import { useMemo, useState } from "react";
-import { Link } from "react-router";
import { useGithubRepoSearch } from "@/hooks/use-github-repo-search";
import type { GithubRepositorySearchResult } from "@/hooks/use-github-repo-search";
@@ -170,13 +168,6 @@ export const RepositorySelector = ({
-
- Import public Git URL
-
-
{availableRepositories.length} available
diff --git a/apps/web/src/components/timeline/timeline-composer.tsx b/apps/web/src/components/timeline/timeline-composer.tsx
new file mode 100644
index 0000000..ff1c2a5
--- /dev/null
+++ b/apps/web/src/components/timeline/timeline-composer.tsx
@@ -0,0 +1,57 @@
+import { Button } from "@code/ui/components/button";
+import { Send } from "lucide-react";
+import type { FormEvent } from "react";
+
+export const TimelineComposer = ({
+ draft,
+ onDraftChange,
+ onSubmit,
+}: {
+ readonly draft: string;
+ readonly onDraftChange: (draft: string) => void;
+ readonly onSubmit: (text: string) => void;
+}) => {
+ const submit = (event: FormEvent
) => {
+ event.preventDefault();
+ const message = draft.trim();
+ if (!message) {
+ return;
+ }
+ onSubmit(message);
+ onDraftChange("");
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/apps/web/src/components/timeline/timeline-event-list.tsx b/apps/web/src/components/timeline/timeline-event-list.tsx
new file mode 100644
index 0000000..841f552
--- /dev/null
+++ b/apps/web/src/components/timeline/timeline-event-list.tsx
@@ -0,0 +1,48 @@
+import { MessageSquareText } from "lucide-react";
+
+import type { LocalTimelineEvent } from "@/hooks/use-global-timeline";
+
+const eventTimeFormatter = new Intl.DateTimeFormat(undefined, {
+ hour: "numeric",
+ minute: "2-digit",
+});
+
+const EmptyTimeline = () => (
+
+
+
+
What should move forward?
+
+ Describe an outcome or problem to add the first event.
+
+
+
+);
+
+export const TimelineEventList = ({
+ events,
+ endRef,
+}: {
+ readonly endRef: React.RefObject;
+ readonly events: readonly LocalTimelineEvent[];
+}) => {
+ if (events.length === 0) {
+ return ;
+ }
+
+ return (
+
+ {events.map((event) => (
+
+
+ {event.text}
+
+
+ You · {eventTimeFormatter.format(event.createdAt)}
+
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/src/hooks/use-global-timeline.ts b/apps/web/src/hooks/use-global-timeline.ts
new file mode 100644
index 0000000..525e9d1
--- /dev/null
+++ b/apps/web/src/hooks/use-global-timeline.ts
@@ -0,0 +1,31 @@
+import { useCallback, useState } from "react";
+
+export interface LocalTimelineEvent {
+ readonly createdAt: number;
+ readonly id: string;
+ readonly text: string;
+ readonly type: "client.prompt";
+}
+
+export const useGlobalTimeline = () => {
+ const [events, setEvents] = useState([]);
+
+ const addPrompt = useCallback((text: string) => {
+ const normalizedText = text.trim();
+ if (!normalizedText) {
+ return;
+ }
+
+ setEvents((current) => [
+ ...current,
+ {
+ createdAt: Date.now(),
+ id: crypto.randomUUID(),
+ text: normalizedText,
+ type: "client.prompt",
+ },
+ ]);
+ }, []);
+
+ return { addPrompt, events };
+};
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 3211a4b..424ea18 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -1,80 +1,10 @@
@import "@code/ui/globals.css";
-@import "streamdown/styles.css";
html,
body {
min-height: 100%;
}
-html.workspace-viewport-lock,
-html.workspace-viewport-lock body {
- height: 100%;
- overflow: hidden;
- overscroll-behavior: none;
-}
-
-.ai-conversation-mobile > div {
- scrollbar-gutter: auto !important;
-}
-
-.chat-message {
- animation: chat-message-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
-}
-
-.response-state {
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- color: var(--muted-foreground);
- font-weight: 400;
-}
-
-.response-state-dots {
- display: inline-flex;
- align-items: center;
- gap: 2px;
-}
-
-.response-state-dots i,
-.thinking-line span {
- border-radius: 999px;
- background: currentColor;
- animation: response-dot 1.25s ease-in-out infinite;
-}
-
-.response-state-dots i {
- width: 3px;
- height: 3px;
-}
-
-.response-state-dots i:nth-child(2),
-.thinking-line span:nth-child(2) {
- animation-delay: 140ms;
-}
-
-.response-state-dots i:nth-child(3),
-.thinking-line span:nth-child(3) {
- animation-delay: 280ms;
-}
-
-.thinking-line {
- display: flex;
- width: fit-content;
- min-height: 1.75rem;
- align-items: center;
- gap: 0.3rem;
- color: color-mix(in oklch, var(--foreground) 42%, transparent);
-}
-
-.thinking-line span {
- width: 5px;
- height: 5px;
-}
-
-.chat-markdown[data-streamdown="true"] {
- min-height: 1.75rem;
-}
-
.route-progress-bar {
animation: route-progress 900ms ease-in-out infinite;
}
@@ -87,192 +17,3 @@ html.workspace-viewport-lock body {
transform: translateX(400%);
}
}
-
-@keyframes response-dot {
- 0%,
- 60%,
- 100% {
- opacity: 0.28;
- transform: translateY(0) scale(0.84);
- }
- 30% {
- opacity: 0.9;
- transform: translateY(-2px) scale(1);
- }
-}
-
-@keyframes chat-message-enter {
- from {
- opacity: 0;
- transform: translateY(5px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-.chat-markdown {
- color: var(--foreground);
-}
-
-/*
- * The chat always renders on the light workspace surface
- * (`bg-[#f2f0e7]`, near-black text), but the app forces the dark theme
- * app-wide (`forcedTheme="dark"`). Streamdown's code/table/mermaid chrome and
- * the rules below read theme tokens, which under `.dark` resolve to near-black
- * values — producing unreadable dark-on-dark blocks. Re-declare the relevant
- * tokens to warm-light values inside the markdown context so every token-driven
- * chrome (headers, copy/download/fullscreen controls, table borders/cells,
- * inline code) reads correctly on the light surface without touching the
- * global theme or unrelated UI.
- */
-.workspace-surface .chat-markdown {
- --background: #ffffff;
- --foreground: #232321;
- --muted: #f4f4f2;
- --muted-foreground: #69675f;
- --card: #ffffff;
- --card-foreground: #232321;
- --popover: #ffffff;
- --popover-foreground: #232321;
- --secondary: #f4f4f2;
- --secondary-foreground: #232321;
- --accent: #efece4;
- --accent-foreground: #232321;
- --sidebar: #ffffff;
- --sidebar-foreground: #232321;
- --sidebar-accent: #f4f4f2;
- --sidebar-accent-foreground: #232321;
- --border: #e2e0d6;
- --input: #e2e0d6;
- --ring: #b6b7b4;
-}
-
-.workspace-surface .chat-markdown,
-.workspace-surface .chat-reasoning,
-.workspace-surface .thinking-line {
- color: #232321;
-}
-
-.chat-markdown > :first-child {
- margin-top: 0;
-}
-
-.chat-markdown > :last-child {
- margin-bottom: 0;
-}
-
-.chat-markdown p,
-.chat-markdown ul,
-.chat-markdown ol,
-.chat-markdown blockquote,
-.chat-markdown pre,
-.chat-markdown table {
- margin-block: 0.8rem;
-}
-
-.chat-markdown h1,
-.chat-markdown h2,
-.chat-markdown h3,
-.chat-markdown h4 {
- margin-block: 1.4rem 0.65rem;
- font-weight: 650;
- line-height: 1.25;
- letter-spacing: -0.02em;
-}
-
-.chat-markdown h1 {
- font-size: 1.5rem;
-}
-
-.chat-markdown h2 {
- font-size: 1.25rem;
-}
-
-.chat-markdown h3,
-.chat-markdown h4 {
- font-size: 1.05rem;
-}
-
-.chat-markdown ul,
-.chat-markdown ol {
- padding-inline-start: 1.4rem;
-}
-
-.chat-markdown ul {
- list-style: disc;
-}
-
-.chat-markdown ol {
- list-style: decimal;
-}
-
-.chat-markdown li + li {
- margin-top: 0.35rem;
-}
-
-.chat-markdown blockquote {
- border-inline-start: 2px solid var(--border);
- padding-inline-start: 1rem;
- color: var(--muted-foreground);
-}
-
-.chat-markdown a {
- color: inherit;
- text-decoration: underline;
- text-decoration-color: var(--muted-foreground);
- text-underline-offset: 3px;
-}
-
-.chat-markdown :not(pre) > code {
- border: 1px solid var(--border);
- background: var(--muted);
- padding: 0.1rem 0.35rem;
- font-size: 0.875em;
-}
-
-.chat-markdown pre {
- max-width: 100%;
- overflow-x: auto;
- border: 1px solid var(--border);
- background: var(--muted);
- padding: 1rem;
- font-size: 0.8125rem;
- line-height: 1.6;
-}
-
-.chat-markdown table {
- display: block;
- width: 100%;
- overflow-x: auto;
- border-collapse: collapse;
-}
-
-.chat-markdown th,
-.chat-markdown td {
- border: 1px solid var(--border);
- padding: 0.5rem 0.75rem;
- text-align: start;
-}
-
-/* Readable header band + zebra rows so tables stand out on the surface. */
-.chat-markdown thead th {
- background: var(--muted);
- font-weight: 600;
-}
-
-.chat-markdown tbody tr:nth-child(even) {
- background: color-mix(in oklch, var(--muted) 55%, transparent);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .chat-markdown *,
- .chat-message,
- .cn-message-scroller-button,
- .response-state-dots i,
- .thinking-line span {
- animation-duration: 0.01ms !important;
- transition-duration: 0.01ms !important;
- }
-}
diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts
index c50a206..7b5ce69 100644
--- a/apps/web/src/routes.ts
+++ b/apps/web/src/routes.ts
@@ -7,7 +7,7 @@ export default [
route("signup", "./routes/auth/signup/page.tsx"),
]),
layout("./routes/app/layout.tsx", [
- index("./routes/app/workspace/page.tsx"),
+ index("./routes/app/timeline/page.tsx"),
route("projects", "./routes/app/projects/page.tsx"),
]),
] satisfies RouteConfig;
diff --git a/apps/web/src/routes/app/layout.tsx b/apps/web/src/routes/app/layout.tsx
index 7cfe4d7..a3a434e 100644
--- a/apps/web/src/routes/app/layout.tsx
+++ b/apps/web/src/routes/app/layout.tsx
@@ -1,9 +1,5 @@
-import { api } from "@code/backend/convex/_generated/api";
-import { useQuery } from "convex/react";
-import { useEffect } from "react";
-import { Outlet, useNavigate } from "react-router";
+import { Outlet } from "react-router";
-import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { requireAuthToken } from "@/lib/auth.server";
import type { Route } from "./+types/layout";
@@ -12,24 +8,5 @@ export const loader = ({ request }: Route.LoaderArgs) =>
requireAuthToken(request);
export default function AppLayout() {
- const organization = usePersonalOrganization();
- const navigate = useNavigate();
- const projects = useQuery(
- api.projects.list,
- organization.organizationId ? {} : "skip"
- );
-
- useEffect(() => {
- if (
- projects !== undefined &&
- projects.length === 0 &&
- window.location.pathname === "/" &&
- !window.location.search.includes("resume=") &&
- !window.location.search.includes("project=")
- ) {
- void navigate("/projects", { replace: true });
- }
- }, [projects, navigate]);
-
return ;
}
diff --git a/apps/web/src/routes/app/projects/page.tsx b/apps/web/src/routes/app/projects/page.tsx
index 79ca18b..d68498a 100644
--- a/apps/web/src/routes/app/projects/page.tsx
+++ b/apps/web/src/routes/app/projects/page.tsx
@@ -3,7 +3,7 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { useEffect, useState } from "react";
-import { useNavigate, useSearchParams } from "react-router";
+import { useSearchParams } from "react-router";
import { AddProjectPanel } from "@/components/projects/add-project-panel";
import { LoadingState } from "@/components/projects/loading-state";
@@ -20,7 +20,6 @@ const connectGithubRef = makeFunctionReference<
>("gitConnections:connectGithub");
export default function ProjectsRoute() {
- const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const projects = useQuery(api.projects.list, {});
const providerAccounts = useQuery(
@@ -68,8 +67,8 @@ export default function ProjectsRoute() {
project.sources.map((source) => source.url)
);
const hasProjects = projects.length > 0;
- const handleProjectCreated = (projectId: string) => {
- void navigate(`/?project=${projectId}`, { replace: true });
+ const handleProjectCreated = () => {
+ pageState.closeAddProject();
};
const handleCloseAddProject = () => {
pageState.closeAddProject();
@@ -78,11 +77,7 @@ export default function ProjectsRoute() {
return (
-
void navigate("/")}
- onOpenAddProject={openAddProject}
- />
+
{resumeError ? (
@@ -106,8 +101,8 @@ export default function ProjectsRoute() {
Connect the repository where work happens.
- Every project has its own conversation, durable context, and work
- history. Start by choosing a repository.
+ Projects keep your repository source and context in one place.
+ Connect a Git provider or import a public repository to start.
)}
diff --git a/apps/web/src/routes/app/timeline/page.tsx b/apps/web/src/routes/app/timeline/page.tsx
new file mode 100644
index 0000000..3523422
--- /dev/null
+++ b/apps/web/src/routes/app/timeline/page.tsx
@@ -0,0 +1,54 @@
+import { FolderGit2 } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { Link } from "react-router";
+
+import { TimelineComposer } from "@/components/timeline/timeline-composer";
+import { TimelineEventList } from "@/components/timeline/timeline-event-list";
+import { useGlobalTimeline } from "@/hooks/use-global-timeline";
+
+export default function GlobalTimelineRoute() {
+ const [draft, setDraft] = useState("");
+ const { addPrompt, events } = useGlobalTimeline();
+ const endRef = useRef(null);
+
+ useEffect(() => {
+ if (events.length > 0) {
+ endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+ }, [events.length]);
+
+ return (
+
+
+
+
+
+
+
+ );
+}