timelines
This commit is contained in:
@@ -25,8 +25,7 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "^8.1.0",
|
||||
"sonner": "catalog:",
|
||||
"streamdown": "catalog:"
|
||||
"sonner": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
|
||||
@@ -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<HTMLDialogElement>(null);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const [repositoryUrl, setRepositoryUrl] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string>();
|
||||
|
||||
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(
|
||||
<dialog
|
||||
aria-labelledby="add-project-heading"
|
||||
@@ -59,6 +89,47 @@ export const AddProjectPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-hidden p-5">
|
||||
<form
|
||||
className="rounded border border-[#d7d3c7] bg-[#fffefa] p-4"
|
||||
onSubmit={importPublicRepository}
|
||||
>
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm font-semibold text-[#20201d]"
|
||||
htmlFor="public-git-url"
|
||||
>
|
||||
<Globe2 className="size-4 text-[#747168]" />
|
||||
Public Git URL
|
||||
</label>
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
className="h-10 min-w-0 flex-1 border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none placeholder:text-[#858277] focus:border-[#55564e]"
|
||||
disabled={importing}
|
||||
id="public-git-url"
|
||||
onChange={(event) => setRepositoryUrl(event.target.value)}
|
||||
placeholder="https://github.com/owner/repository"
|
||||
type="url"
|
||||
value={repositoryUrl}
|
||||
/>
|
||||
<Button
|
||||
className="h-10 shrink-0"
|
||||
disabled={importing}
|
||||
type="submit"
|
||||
>
|
||||
{importing ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Globe2 className="size-4" />
|
||||
)}
|
||||
{importing ? "Importing" : "Import public Git"}
|
||||
</Button>
|
||||
</div>
|
||||
{importError ? (
|
||||
<p className="mt-2 text-xs leading-5 text-red-700">
|
||||
{importError}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<ProviderChips accounts={accounts} />
|
||||
|
||||
<RepositorySelector
|
||||
|
||||
@@ -9,7 +9,7 @@ export const GitHubConnectButton = () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await authClient.linkSocial({
|
||||
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||
callbackURL: `${window.location.origin}/?resume=github`,
|
||||
provider: "github",
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ProjectsGrid = ({
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#68665e]">
|
||||
Choose a repository to continue its project conversation.
|
||||
Open a project to manage its repository context.
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative block w-full sm:w-64">
|
||||
|
||||
@@ -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;
|
||||
}) => (
|
||||
<header className="flex flex-col gap-5 border-b border-[#d7d3c7] pb-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -17,35 +13,21 @@ export const ProjectsHeader = ({
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
|
||||
Zopu workspace
|
||||
Zopu
|
||||
</p>
|
||||
<h1 className="mt-0.5 text-2xl font-semibold tracking-tight text-[#20201d]">
|
||||
Projects
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasProjects ? (
|
||||
<Button
|
||||
className="border-[#c9c5b9] bg-[#fffefa] text-[#20201d] hover:bg-[#f5f3eb]"
|
||||
onClick={onGoHome}
|
||||
size="lg"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Workspace
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className="bg-[#20201d] text-[#fffefa] hover:bg-[#3a3933]"
|
||||
onClick={onOpenAddProject}
|
||||
size="lg"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add project
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-[#20201d] text-[#fffefa] hover:bg-[#3a3933]"
|
||||
onClick={onOpenAddProject}
|
||||
size="lg"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add project
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Link
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-[#20201d] underline decoration-[#858277] underline-offset-4 hover:decoration-[#20201d]"
|
||||
to="/?import=public"
|
||||
>
|
||||
Import public Git URL
|
||||
<ArrowUpRight className="size-3" />
|
||||
</Link>
|
||||
<p className="text-xs tabular-nums text-[#858277]">
|
||||
{availableRepositories.length} available
|
||||
</p>
|
||||
|
||||
@@ -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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const message = draft.trim();
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
onSubmit(message);
|
||||
onDraftChange("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
<form
|
||||
aria-label="Add timeline event"
|
||||
className="mx-auto flex max-w-2xl items-end gap-2"
|
||||
onSubmit={submit}
|
||||
>
|
||||
<textarea
|
||||
aria-label="Add a timeline event"
|
||||
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-base text-[#20201d] outline-none placeholder:text-[#858277] focus:border-[#55564e]"
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe an outcome or problem…"
|
||||
rows={1}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Add event"
|
||||
className="size-11 shrink-0"
|
||||
disabled={!draft.trim()}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 = () => (
|
||||
<div className="grid min-h-[55vh] place-items-center text-center">
|
||||
<div>
|
||||
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
||||
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
||||
Describe an outcome or problem to add the first event.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TimelineEventList = ({
|
||||
events,
|
||||
endRef,
|
||||
}: {
|
||||
readonly endRef: React.RefObject<HTMLDivElement | null>;
|
||||
readonly events: readonly LocalTimelineEvent[];
|
||||
}) => {
|
||||
if (events.length === 0) {
|
||||
return <EmptyTimeline />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-live="polite" className="flex flex-col gap-5" role="log">
|
||||
{events.map((event) => (
|
||||
<article className="ml-auto max-w-[min(88%,42rem)]" key={event.id}>
|
||||
<div className="border border-[#c9c5b9] bg-white px-4 py-3 text-sm leading-6 text-[#20201d] shadow-[0_1px_0_rgba(32,32,29,0.04)]">
|
||||
{event.text}
|
||||
</div>
|
||||
<p className="mt-1 text-right text-[10px] font-medium tracking-[0.14em] text-[#858277] uppercase">
|
||||
You · {eventTimeFormatter.format(event.createdAt)}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
<div aria-hidden="true" ref={endRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<readonly LocalTimelineEvent[]>([]);
|
||||
|
||||
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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <Outlet />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<main className="min-h-svh bg-[#f2f0e7] px-5 py-7 text-[#20201d] sm:px-8 sm:py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<ProjectsHeader
|
||||
hasProjects={hasProjects}
|
||||
onGoHome={() => void navigate("/")}
|
||||
onOpenAddProject={openAddProject}
|
||||
/>
|
||||
<ProjectsHeader onOpenAddProject={openAddProject} />
|
||||
|
||||
{resumeError ? (
|
||||
<p className="mt-6 border border-red-300 bg-red-50 p-3 text-xs leading-5 text-red-700">
|
||||
@@ -106,8 +101,8 @@ export default function ProjectsRoute() {
|
||||
Connect the repository where work happens.
|
||||
</h2>
|
||||
<p className="mt-3 max-w-xl text-base leading-7 text-[#68665e]">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (events.length > 0) {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, [events.length]);
|
||||
|
||||
return (
|
||||
<main className="fixed inset-0 flex min-h-0 flex-col overflow-hidden bg-[#f2f0e7] text-[#20201d]">
|
||||
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-sm font-semibold">Global timeline</h1>
|
||||
<p className="text-[10px] tracking-[0.14em] text-[#858277] uppercase">
|
||||
Organization activity
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
aria-label="Projects"
|
||||
className="grid size-9 place-items-center border border-[#c9c5b9] bg-[#fffefa] text-[#20201d] transition-colors hover:bg-[#f5f3eb]"
|
||||
to="/projects"
|
||||
>
|
||||
<FolderGit2 className="size-4" />
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section
|
||||
aria-label="Global timeline events"
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="mx-auto min-h-full w-full max-w-2xl px-4 py-5 sm:px-6">
|
||||
<TimelineEventList endRef={endRef} events={events} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TimelineComposer
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onSubmit={addPrompt}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user