diff --git a/.gitignore b/.gitignore index 5e05476..9644f4d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ tsconfig.tsbuildinfo # env .env +.env* .env.local .env*.local diff --git a/CLAUDE.md b/CLAUDE.md index 6a5135d..02b0eb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,9 +59,11 @@ nextkit house rules (see the imported guide): tabs/width-4/88 via oxfmt, no subsystem knowledge goes in `docs/`. + # This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + diff --git a/README.md b/README.md index 6ad6fb7..5108ad0 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,13 @@ bun install bun run dev # PGlite (no Docker) + migrations + next dev on :3000 ``` -Google sign-in gates every page, so `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` -are required to use the app at all; `ANTHROPIC_API_KEY` is needed only for the -agent chat. Get both from `vercel env pull`. Nothing else needs configuring -locally — `bun run dev` boots its own database. +Google sign-in gates every page by default, so `GOOGLE_CLIENT_ID`/ +`GOOGLE_CLIENT_SECRET` are required to use the app at all; `ANTHROPIC_API_KEY` +is needed only for the agent chat. For local work without Google OAuth, set +`DEV_EMAIL_PASSWORD_SIGN_IN=true` before `bun run dev` and create an account on +the login page. This option is ignored outside development. Get the remaining +secrets from `vercel env pull`. Nothing else needs configuring locally — `bun +run dev` boots its own database. ## Env @@ -43,5 +46,6 @@ locally — `bun run dev` boots its own database. | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Shared Ingram Google OAuth client (from the infra `platform` stack) | | `BETTER_AUTH_SECRET` | Session signing secret (dev falls back to a placeholder) | | `BETTER_AUTH_URL` | Canonical origin, `https://sheets.ingram.tech` in prod | +| `DEV_EMAIL_PASSWORD_SIGN_IN` | Set to `true` to enable Better Auth email/password sign-in and local account creation in `NODE_ENV=development` only | | `NEXT_PUBLIC_GOOGLE_PICKER_API_KEY` | Optional — API key (same Google project as the OAuth client) enabling "Browse Google Drive" via the Google Picker | | `NEXT_PUBLIC_GOOGLE_PICKER_APP_ID` | Optional — that Google project's number (Picker `setAppId`) | diff --git a/bun.lock b/bun.lock index 6928edd..22c8df5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,45 +5,46 @@ "": { "name": "sheets", "dependencies": { - "@ai-sdk/react": "latest", - "@base-ui/react": "latest", - "@better-auth/passkey": "latest", - "@ingram-cloud/ai-sdk": "latest", - "@ingram-cloud/sdk": "latest", - "@ingram-tech/nk-auth": "latest", - "@ingram-tech/nk-db": "latest", + "@ai-sdk/react": "^4.0.54", + "@base-ui/react": "^1.7.0", + "@better-auth/passkey": "^1.6.25", + "@ingram-cloud/ai-sdk": "^2.0.0", + "@ingram-cloud/sdk": "^1.4.0", + "@ingram-tech/nk-auth": "^0.13.3", + "@ingram-tech/nk-db": "^1.4.2", "@ironcalc/wasm": "file:vendor/ironcalc-wasm", - "ai": "latest", - "better-auth": "latest", - "class-variance-authority": "latest", - "clsx": "latest", - "drizzle-orm": "latest", - "exceljs": "latest", - "lucide-react": "latest", - "next": "latest", - "pg": "latest", - "react": "latest", - "react-dom": "latest", + "ai": "^7.0.51", + "better-auth": "^1.6.25", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-orm": "^0.45.2", + "exceljs": "^4.4.0", + "lucide-react": "^1.28.0", + "next": "^16.3.0", + "pg": "^8.22.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", "sheetkit-wasm": "file:vendor/sheetkit-wasm", - "sonner": "latest", - "streamdown": "latest", - "tailwind-merge": "latest", - "zod": "latest", + "sonner": "^2.0.7", + "streamdown": "^2.5.0", + "tailwind-merge": "^3.6.0", + "use-stick-to-bottom": "^1.1.6", + "zod": "^4.4.3", }, "devDependencies": { - "@electric-sql/pglite": "latest", - "@electric-sql/pglite-socket": "latest", - "@ingram-tech/nk-dev": "latest", - "@tailwindcss/postcss": "latest", - "@types/node": "latest", - "@types/pg": "latest", - "@types/react": "latest", - "@types/react-dom": "latest", - "drizzle-kit": "latest", - "tailwindcss": "latest", - "tsx": "latest", - "typescript": "latest", - "vitest": "latest", + "@electric-sql/pglite": "^0.5.4", + "@electric-sql/pglite-socket": "^0.2.7", + "@ingram-tech/nk-dev": "^0.10.0", + "@tailwindcss/postcss": "^4.3.3", + "@types/node": "^26.1.2", + "@types/pg": "^8.20.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "drizzle-kit": "^0.31.10", + "tailwindcss": "^4.3.3", + "tsx": "^4.23.5", + "typescript": "^7.0.2", + "vitest": "^4.1.10", }, }, }, @@ -340,8 +341,6 @@ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.142.0", "", { "os": "android", "cpu": "arm" }, "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ=="], @@ -1520,6 +1519,8 @@ "unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g=="], + "use-stick-to-bottom": ["use-stick-to-bottom@1.1.6", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-z3Up8jYQGTkUCsGBnwg6/wj70KgXoW5Kz1AAc1j8MtQuYMBo6ZsdhrIXoegxa7gaMMilgQYyTohTrt3p94jHog=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/package.json b/package.json index 6b1c284..aa7d4d3 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "sonner": "^2.0.7", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", + "use-stick-to-bottom": "^1.1.6", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/app/globals.css b/src/app/globals.css index 720bc0d..6c4cff9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -247,6 +247,37 @@ } @layer base { + /* + * Reserve the scrollbar gutter on pages that opt in with + * `data-stable-gutter`. + * + * Without it the viewport WIDTH depends on the page HEIGHT, which makes a + * responsive breakpoint bistable. The workbook list is the case that + * exposed it: below `sm` every row carries a second meta line and the + * footer wraps, so the page is ~100px taller and needs a scrollbar; above + * `sm` it doesn't. Each mode is then self-consistent over a narrow band of + * widths — tall enough to need the scrollbar that keeps it narrow, or short + * enough to drop the scrollbar that keeps it wide — so within that band the + * layout holds whichever mode it happened to reach, and a one-pixel resize + * flips it. Chrome compounds this by matching `min-width` against the + * viewport INCLUDING the classic scrollbar while laying the content out in + * what's left, so at the breakpoint the query and the layout disagree by + * the scrollbar's width. + * + * A reserved gutter breaks the loop: height can no longer change width, so + * the breakpoint is one step instead of a band. + * + * `overflow-y: auto` is load-bearing — `scrollbar-gutter` only applies to a + * real scroll container, and on a root with the default `visible` it is + * silently ignored. `:has()` keeps both off every other surface: the + * workbook is `h-dvh` with its own inner scrollers and must not lose 15px + * of grid to a gutter it never uses. + */ + html:has([data-stable-gutter]) { + overflow-y: auto; + scrollbar-gutter: stable; + } + /* * Default border colour. Tailwind v4 dropped v3's preflight that reset every * element's border to gray-200, so a bare `border` / `border-b` now paints in @@ -261,6 +292,23 @@ border-color: var(--color-border); } + /* + * Chrome strips that carry their own scroll affordance. + * + * Only for the toolbar and the sheet-tab strip (see ScrollStrip), where the + * row is 36–40px tall and a classic scrollbar takes a third of it while + * still reading as a rendering defect. Those strips replace it with an edge + * fade plus a chevron, so the scroll stays both visible and operable — this + * is never a licence to hide a scrollbar on a real scrolling surface. + */ + .scrollbar-none { + scrollbar-width: none; + } + + .scrollbar-none::-webkit-scrollbar { + display: none; + } + /* * Spreadsheet chrome is dense and keyboard-first: a visible focus ring is * the difference between usable and unusable. Base UI primitives ship diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 977ba23..702cff9 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -1,10 +1,11 @@ "use client"; import { Loader2Icon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import { SheetsMark } from "@/components/brand/sheets-mark"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; // If the handoff to Google hasn't navigated away by now, something is wrong @@ -12,9 +13,16 @@ import { authClient } from "@/lib/auth-client"; // leaving "Redirecting…" disabled forever. const REDIRECT_TIMEOUT_MS = 8000; -export function LoginForm({ next }: { next: string }) { +export function LoginForm({ + next, + enableDevEmailPassword, +}: { + next: string; + enableDevEmailPassword: boolean; +}) { const [pending, setPending] = useState(false); const [error, setError] = useState(null); + const [isCreatingDevAccount, setIsCreatingDevAccount] = useState(false); const timer = useRef | null>(null); useEffect(() => { @@ -53,6 +61,27 @@ export function LoginForm({ next }: { next: string }) { } }; + const signInWithEmailPassword = async (event: FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const email = String(form.get("email") ?? ""); + const password = String(form.get("password") ?? ""); + const name = String(form.get("name") ?? ""); + + setPending(true); + setError(null); + try { + const result = isCreatingDevAccount + ? await authClient.signUp.email({ name, email, password }) + : await authClient.signIn.email({ email, password }); + if (result.error) throw new Error(result.error.message ?? "Sign-in failed"); + window.location.assign(next); + } catch (caught) { + setPending(false); + setError(caught instanceof Error ? caught.message : "Sign-in failed"); + } + }; + return (
@@ -80,6 +109,74 @@ export function LoginForm({ next }: { next: string }) { )} {pending ? "Redirecting…" : "Continue with Google"} + {enableDevEmailPassword ? ( + <> +
+ Local development +
+
+ {isCreatingDevAccount ? ( + + ) : null} + + + +
+ + + ) : null} {error ? (

; + return ( + + ); } diff --git a/src/components/ai-elements/conversation.tsx b/src/components/ai-elements/conversation.tsx new file mode 100644 index 0000000..90d7bdf --- /dev/null +++ b/src/components/ai-elements/conversation.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { UIMessage } from "ai"; +import { ArrowDownIcon, DownloadIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +

+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; + +const getMessageText = (message: UIMessage): string => + message.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); + +export type ConversationDownloadProps = Omit< + ComponentProps, + "onClick" +> & { + messages: UIMessage[]; + filename?: string; + formatMessage?: (message: UIMessage, index: number) => string; +}; + +const defaultFormatMessage = (message: UIMessage): string => { + const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1); + return `**${roleLabel}:** ${getMessageText(message)}`; +}; + +export const messagesToMarkdown = ( + messages: UIMessage[], + formatMessage: (message: UIMessage, index: number) => string = defaultFormatMessage, +): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n"); + +export const ConversationDownload = ({ + messages, + filename = "conversation.md", + formatMessage = defaultFormatMessage, + className, + children, + ...props +}: ConversationDownloadProps) => { + const handleDownload = useCallback(() => { + const markdown = messagesToMarkdown(messages, formatMessage); + const blob = new Blob([markdown], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }, [messages, filename, formatMessage]); + + return ( + + ); +}; diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx new file mode 100644 index 0000000..95606cf --- /dev/null +++ b/src/components/ai-elements/message.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { UIMessage } from "ai"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { Streamdown } from "streamdown"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageActionsProps = ComponentProps<"div">; + +export const MessageActions = ({ + className, + children, + ...props +}: MessageActionsProps) => ( +
+ {children} +
+); + +export type MessageActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const MessageAction = ({ + tooltip, + children, + label, + variant = "ghost", + size = "icon-sm", + ...props +}: MessageActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +interface MessageBranchContextType { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +} + +const MessageBranchContext = createContext(null); + +const useMessageBranch = () => { + const context = useContext(MessageBranchContext); + + if (!context) { + throw new Error("MessageBranch components must be used within MessageBranch"); + } + + return context; +}; + +export type MessageBranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const MessageBranch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: MessageBranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = useCallback( + (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }, + [onBranchChange], + ); + + const goToPrevious = useCallback(() => { + const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }, [currentBranch, branches.length, handleBranchChange]); + + const goToNext = useCallback(() => { + const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }, [currentBranch, branches.length, handleBranchChange]); + + const contextValue = useMemo( + () => ({ + branches, + currentBranch, + goToNext, + goToPrevious, + setBranches, + totalBranches: branches.length, + }), + [branches, currentBranch, goToNext, goToPrevious], + ); + + return ( + +
div]:pb-0", className)} + {...props} + /> + + ); +}; + +export type MessageBranchContentProps = HTMLAttributes; + +export const MessageBranchContent = ({ + children, + ...props +}: MessageBranchContentProps) => { + const { currentBranch, setBranches, branches } = useMessageBranch(); + const childrenArray = useMemo( + () => (Array.isArray(children) ? children : [children]), + [children], + ); + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0", + index === currentBranch ? "block" : "hidden", + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type MessageBranchSelectorProps = ComponentProps; + +export const MessageBranchSelector = ({ + className, + ...props +}: MessageBranchSelectorProps) => { + const { totalBranches } = useMessageBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( + *:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md", + className, + )} + orientation="horizontal" + {...props} + /> + ); +}; + +export type MessageBranchPreviousProps = ComponentProps; + +export const MessageBranchPrevious = ({ + children, + ...props +}: MessageBranchPreviousProps) => { + const { goToPrevious, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchNextProps = ComponentProps; + +export const MessageBranchNext = ({ children, ...props }: MessageBranchNextProps) => { + const { goToNext, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchPageProps = HTMLAttributes; + +export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => { + const { currentBranch, totalBranches } = useMessageBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; + +export type MessageResponseProps = ComponentProps; + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0", + className, + )} + {...props} + /> + ), + (prevProps, nextProps) => + prevProps.children === nextProps.children && + nextProps.isAnimating === prevProps.isAnimating, +); + +MessageResponse.displayName = "MessageResponse"; + +export type MessageToolbarProps = ComponentProps<"div">; + +export const MessageToolbar = ({ + className, + children, + ...props +}: MessageToolbarProps) => ( +
+ {children} +
+); diff --git a/src/components/ai-elements/suggestion.tsx b/src/components/ai-elements/suggestion.tsx new file mode 100644 index 0000000..8f72db9 --- /dev/null +++ b/src/components/ai-elements/suggestion.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; + +export type SuggestionsProps = ComponentProps; + +export const Suggestions = ({ className, children, ...props }: SuggestionsProps) => ( + +
+ {children} +
+ +
+); + +export type SuggestionProps = Omit, "onClick"> & { + suggestion: string; + onClick?: (suggestion: string) => void; +}; + +export const Suggestion = ({ + suggestion, + onClick, + className, + variant = "outline", + size = "sm", + children, + ...props +}: SuggestionProps) => { + const handleClick = useCallback(() => { + onClick?.(suggestion); + }, [onClick, suggestion]); + + return ( + + ); +}; diff --git a/src/components/ai-elements/tool.tsx b/src/components/ai-elements/tool.tsx new file mode 100644 index 0000000..26655fc --- /dev/null +++ b/src/components/ai-elements/tool.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import type { DynamicToolUIPart, ToolUIPart } from "ai"; +import { + CheckCircleIcon, + ChevronDownIcon, + CircleAlertIcon, + ClockIcon, + Loader2Icon, + XCircleIcon, +} from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { isValidElement, useId } from "react"; + +export type ToolProps = ComponentProps; + +export const Tool = ({ className, ...props }: ToolProps) => ( + +); + +export type ToolPart = ToolUIPart | DynamicToolUIPart; + +export type ToolHeaderProps = { + title?: string; + className?: string; + /** Rendered after the title — e.g. the range this call acted on. */ + children?: ReactNode; +} & ( + | { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never } + | { + type: DynamicToolUIPart["type"]; + state: DynamicToolUIPart["state"]; + toolName: string; + } +); + +const statusLabels: Record = { + "approval-requested": "Awaiting Approval", + "approval-responded": "Responded", + "input-available": "Running", + "input-streaming": "Pending", + "output-available": "Completed", + "output-denied": "Denied", + "output-error": "Error", +}; + +const statusIcons: Record = { + "approval-requested": , + "approval-responded": , + "input-available": , + "input-streaming": , + "output-available": , + "output-denied": , + "output-error": , +}; + +export const getStatusBadge = (status: ToolPart["state"]) => ( + + {statusIcons[status]} + {statusLabels[status]} + +); + +export const ToolHeader = ({ + className, + title, + type, + state, + toolName, + children, + ...props +}: ToolHeaderProps) => { + const derivedName = + type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-"); + const titleId = useId(); + + return ( +
+ {/* + * The trigger is a stretched overlay, not a wrapper: the row carries + * its own controls (the range target), and a button inside a button + * is invalid HTML that React refuses to hydrate. It still covers the + * whole row, and takes its name from the visible title. + */} + + {getStatusBadge(state)} + + {title ?? derivedName} + + {children ? ( + + {children} + + ) : null} + +
+ ); +}; + +export type ToolContentProps = ComponentProps; + +export const ToolContent = ({ className, ...props }: ToolContentProps) => ( + +); + +export type ToolInputProps = ComponentProps<"div"> & { + input: ToolPart["input"]; +}; + +export const ToolInput = ({ className, input, ...props }: ToolInputProps) => ( +
+

+ Parameters +

+
+			{JSON.stringify(input, null, 2)}
+		
+
+); + +export type ToolOutputProps = ComponentProps<"div"> & { + output: ToolPart["output"]; + errorText: ToolPart["errorText"]; +}; + +export const ToolOutput = ({ + className, + output, + errorText, + ...props +}: ToolOutputProps) => { + if (!(output || errorText)) { + return null; + } + + const text = + errorText ?? + (typeof output === "string" + ? output + : isValidElement(output) + ? null + : JSON.stringify(output, null, 2)); + + return ( +
+

+ {errorText ? "Error" : "Result"} +

+ {/* + * `text-destructive` is the oxblood FILL and measures 1.81:1 on this + * app's charcoal — the ink token is the readable one. See the + * destructive block in globals.css. + */} +
+				{text ?? (output as ReactNode)}
+			
+
+ ); +}; diff --git a/src/components/chat/ChatPanel.tsx b/src/components/chat/ChatPanel.tsx index 2d75073..0e0f948 100644 --- a/src/components/chat/ChatPanel.tsx +++ b/src/components/chat/ChatPanel.tsx @@ -5,13 +5,18 @@ import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls, + type ToolUIPart, } from "ai"; import { ArrowUpIcon, - CircleAlertIcon, - Loader2Icon, + CheckIcon, + CopyIcon, + CornerDownLeftIcon, + MessageSquarePlusIcon, PencilLineIcon, + RotateCcwIcon, SparklesIcon, + SquareDashedMousePointerIcon, SquareIcon, } from "lucide-react"; import { @@ -23,10 +28,33 @@ import { useState, useSyncExternalStore, } from "react"; -import { Streamdown } from "streamdown"; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { + Message, + MessageAction, + MessageActions, + MessageContent, + MessageResponse, +} from "@/components/ai-elements/message"; +import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion"; +import { + getStatusBadge, + Tool, + ToolContent, + ToolHeader, + ToolOutput, +} from "@/components/ai-elements/tool"; import { SetupWizard } from "@/components/inference/SetupWizard"; import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { formatRange, parseRange } from "@/lib/a1"; import type { AgentToolName } from "@/lib/agent-tools"; import { agentToolSchemas } from "@/lib/agent-tools"; import { isInferenceConfigured } from "@/lib/inference-prefs"; @@ -66,27 +94,6 @@ function isAgentToolName(name: string): name is AgentToolName { return name in agentToolSchemas; } -/** - * A burst of the user's own edits, shown as a passive transcript line — the - * visual twin of the agent's tool rows, in the user's pencil rather than the - * agent's dot. Never a message: the agent receives these as context on the - * next turn and is told not to respond to them. - */ -function UserEditChip({ - burst, - multiSheet, -}: { - burst: UserEditBurst; - multiSheet: boolean; -}) { - return ( -
- - {burstChipText(burst, multiSheet)} -
- ); -} - export function ChatPanel({ controller, renameDocument, @@ -96,7 +103,7 @@ export function ChatPanel({ }) { const [input, setInput] = useState(""); const [setupOpen, setSetupOpen] = useState(false); - const scrollRef = useRef(null); + const composerRef = useRef(null); // The user-delta echo: cells the user edits by hand accumulate here, show // as passive chips in the transcript, and reach the agent at the earliest // seam — appended to its next tool result mid-task (the executor drains @@ -110,10 +117,6 @@ export function ChatPanel({ [controller, renameDocument, editLog], ); const lastMessageIdRef = useRef(null); - // Whether the view is pinned to the newest message. Only then does new - // content scroll — otherwise reading back through history during a stream - // is impossible, because every token yanks you to the bottom. - const pinned = useRef(true); // Every request carries a fresh workbook sketch (the user may have edited // cells since the last turn), injected server-side into the latest user @@ -149,18 +152,26 @@ export function ChatPanel({ [controller, editLog], ); - const { messages, sendMessage, addToolOutput, status, error, stop, regenerate } = - useChat({ - transport, - sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, - async onToolCall({ toolCall }) { - if (toolCall.dynamic) return; - const name = toolCall.toolName; - if (!isAgentToolName(name)) return; - const output = await executor.execute(name, toolCall.input); - addToolOutput({ tool: name, toolCallId: toolCall.toolCallId, output }); - }, - }); + const { + messages, + sendMessage, + addToolOutput, + status, + error, + stop, + regenerate, + setMessages, + } = useChat({ + transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + async onToolCall({ toolCall }) { + if (toolCall.dynamic) return; + const name = toolCall.toolName; + if (!isAgentToolName(name)) return; + const output = await executor.execute(name, toolCall.input); + addToolOutput({ tool: name, toolCallId: toolCall.toolCallId, output }); + }, + }); // Anchor for user-edit bursts: the newest message at the moment of the edit. lastMessageIdRef.current = messages[messages.length - 1]?.id ?? null; @@ -205,18 +216,6 @@ export function ChatPanel({ } }, [controller, status]); - const onScroll = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - // 24px of slack so a near-bottom position still counts as pinned. - pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; - }, []); - - useEffect(() => { - const el = scrollRef.current; - if (el && pinned.current) el.scrollTop = el.scrollHeight; - }, [messages, bursts]); - const busy = status === "streaming" || status === "submitted"; const send = (text: string) => { @@ -227,7 +226,6 @@ export function ChatPanel({ setSetupOpen(true); return; } - pinned.current = true; setInput(""); void sendMessage({ text: text.trim() }); }; @@ -237,156 +235,236 @@ export function ChatPanel({ send(input); }; + /** + * Put the live selection into the message being written. + * + * The panel and the grid share one document, and the sentence the user is + * about to type almost always ends in a range. Without this they read the + * reference off the grid and re-type it, which is the one step in this + * product where a human does OCR for a machine that already knows. + */ + const insertSelection = useCallback(() => { + const view = controller.selectedView(); + const reference = formatRange({ + startRow: Math.min(view.range[0], view.range[2]), + startCol: Math.min(view.range[1], view.range[3]), + endRow: Math.max(view.range[0], view.range[2]), + endCol: Math.max(view.range[1], view.range[3]), + }); + const sheets = controller.sheets(); + const name = sheets[view.sheet]?.name; + // Qualify only when it disambiguates; "Sheet1!A1" in a one-sheet + // workbook is noise the agent has to parse past. + const target = sheets.length > 1 && name ? `${name}!${reference}` : reference; + setInput((current) => + current === "" || current.endsWith(" ") + ? `${current}${target} ` + : `${current} ${target} `, + ); + composerRef.current?.focus(); + }, [controller]); + + const newChat = useCallback(() => { + setMessages([]); + editLog.clear(); + setInput(""); + controller.clearHighlights(); + composerRef.current?.focus(); + }, [controller, editLog, setMessages]); + return (
- + Agent -
- -
- {messages.length === 0 ? ( -
-

- Ask for anything — build a table, write formulas, clean up - data. You'll see the agent work in the grid, live. -

-
- {SUGGESTIONS.map((suggestion) => ( - - ))} -
-
+ + + } + /> + New chat + ) : null} +
- {(burstsByAnchor.get(null) ?? []).map((burst) => ( - - ))} - - {messages.map((message) => ( - -
- {message.parts.map((part, index) => { - if (part.type === "text") { - if (!part.text) return null; - if (message.role === "user") { - return ( -
- {part.text} -
- ); - } - return ( -
- {part.text} -
- ); - } - if (isToolUIPart(part)) { - const name = part.type.replace(/^tool-/, ""); - const label = isAgentToolName(name) - ? TOOL_LABELS[name] - : name; - const running = - part.state === "input-streaming" || - part.state === "input-available"; - const output = - typeof part.output === "string" - ? part.output - : ""; - const failed = - part.state === "output-available" && - output.startsWith("error:"); - return ( -
- {running ? ( - - ) : failed ? ( - - ) : ( - + + + {messages.length === 0 ? ( + +

+ Ask for anything — build a table, write formulas, clean + up data. You'll see the agent work in the grid, + live. +

+ {/* + * Upstream lays suggestions out as a horizontally + * scrolling row of pills. At this panel's width a + * full sentence would run off the edge behind a + * scrollbar, so they stack — the same component, + * turned down its other axis. + */} + + {SUGGESTIONS.map((suggestion) => ( + + ))} + +
+ ) : null} + + {(burstsByAnchor.get(null) ?? []).map((burst) => ( + + ))} + + {messages.map((message, index) => { + const text = message.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); + const isNewest = index === messages.length - 1; + return ( + + + + {message.parts.map((part, partIndex) => { + if (part.type === "text") { + if (!part.text) return null; + if (message.role === "user") { + return ( + + {part.text} + + ); + } + return ( + + {part.text} + + ); + } + // Every tool this app exposes is statically + // declared in agent-tools, so a + // dynamic-tool part means a name the + // executor cannot run — nothing to show. + if ( + isToolUIPart(part) && + part.type !== "dynamic-tool" + ) { + return ( + + ); + } + return null; + })} + + {message.role === "assistant" && text.trim() ? ( + - {label} -
- - {/* The actual error was previously discarded — - only a red icon survived. */} - {failed ? ( - + + {isNewest && !busy ? ( + void regenerate()} > - {output.replace(/^error:\s*/, "")} - + + ) : null} -
- ); - } - return null; - })} -
- {(burstsByAnchor.get(message.id) ?? []).map((burst) => ( - - ))} -
- ))} - - {error ? ( -
-

{error.message || "Something went wrong — try again."}

- -
- ) : null} -
+

+ {error.message || "Something went wrong — try again."} +

+ +
+ ) : null} + + + {/* + * Reading back through a long run used to strand you: the panel + * stopped following, and the only way back to the live edge was + * to scroll it by hand. `title` because the control is a single + * glyph — the accessible name alone reaches assistive tech and + * leaves a sighted reader guessing. + */} + + {/* * The agent's whole turn — status, each tool call, the reply — is @@ -398,44 +476,76 @@ export function ChatPanel({

-
-