img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx
index 44cc6ab9..c5c642ed 100644
--- a/web/src/components/ui/checkbox.tsx
+++ b/web/src/components/ui/checkbox.tsx
@@ -1,8 +1,7 @@
-import * as React from 'react'
-import { Checkbox as CheckboxPrimitive } from 'radix-ui'
-
-import { cn } from '@/lib/utils'
-import { CheckIcon } from 'lucide-react'
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { Checkbox as CheckboxPrimitive } from "radix-ui"
+import { CheckIcon } from "lucide-react"
function Checkbox({
className,
@@ -12,8 +11,8 @@ function Checkbox({
@@ -21,7 +20,8 @@ function Checkbox({
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
-
+
)
diff --git a/web/src/components/ui/hover-card.tsx b/web/src/components/ui/hover-card.tsx
new file mode 100644
index 00000000..de7fc275
--- /dev/null
+++ b/web/src/components/ui/hover-card.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
+import { cn } from "@/lib/utils";
+
+function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
+ return
;
+}
+
+function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
+ return (
+
+ );
+}
+
+function HoverCardContent({
+ className,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 4,
+ ...props
+}: PreviewCardPrimitive.Popup.Props &
+ Pick<
+ PreviewCardPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent };
diff --git a/web/src/components/ui/skeleton.tsx b/web/src/components/ui/skeleton.tsx
index 6756d1d2..0118624f 100644
--- a/web/src/components/ui/skeleton.tsx
+++ b/web/src/components/ui/skeleton.tsx
@@ -1,10 +1,10 @@
-import { cn } from '@/lib/utils'
+import { cn } from "@/lib/utils"
-function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
)
diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx
index 2dc68aa5..505b68bc 100644
--- a/web/src/components/ui/sonner.tsx
+++ b/web/src/components/ui/sonner.tsx
@@ -1,42 +1,79 @@
-import { Toaster as Sonner, type ToasterProps } from 'sonner'
+"use client";
+
+import { cn } from "@/lib/utils";
import {
CircleCheckIcon,
InfoIcon,
- TriangleAlertIcon,
- OctagonXIcon,
Loader2Icon,
-} from 'lucide-react'
-import { useTheme } from '@/hooks/useTheme'
+ OctagonXIcon,
+ TriangleAlertIcon,
+ XIcon,
+} from "lucide-react";
+import { useTheme } from "@/hooks/useTheme";
+import { Toaster as Sonner, type ToasterProps } from "sonner";
+
+import { buttonVariants } from "@/components/ui/button";
+
+// Status colours fall back to Notra's values when the project has no
+// --success, --warning or --info tokens of its own.
+const statusTokens = cn(
+ "[--toast-info:var(--info,oklch(0.55_0.13_250))] [--toast-success:var(--success,oklch(0.55_0.109_155))] [--toast-warning:var(--warning,oklch(0.57_0.113_55))]",
+ "dark:[--toast-info:var(--info,oklch(0.7_0.12_250))] dark:[--toast-success:var(--success,oklch(0.7_0.125_155))] dark:[--toast-warning:var(--warning,oklch(0.7_0.125_55))]"
+);
+
+// The Depth surface of the outline button: squircle corners, a soft vertical
+// gradient, an inset top highlight and a layered drop shadow.
+const toastClassName = cn(
+ "group/toast border-border bg-background from-background to-muted/60 text-popover-foreground flex w-(--width) items-center gap-2.5 rounded-[0.875rem] border bg-linear-to-b p-3 text-sm [corner-shape:squircle]",
+ "shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_1px_2px_rgba(0,0,0,0.06),0_8px_24px_-8px_rgba(0,0,0,0.14)]",
+ "dark:border-input dark:bg-muted dark:from-input dark:to-muted dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_2px_rgba(0,0,0,0.4),0_8px_24px_-8px_rgba(0,0,0,0.6)]",
+ // Sonner only hides the content of stacked toasts when it styles them itself.
+ "*:transition-opacity data-[expanded=false]:data-[front=false]:*:opacity-0"
+);
+
+const iconClassName = cn(
+ "bg-muted text-muted-foreground relative flex size-6 shrink-0 items-center justify-center rounded-lg [corner-shape:squircle] [&_svg]:size-3.5",
+ "group-data-[type=success]/toast:bg-(--toast-success)/12 group-data-[type=success]/toast:text-(--toast-success)",
+ "group-data-[type=error]/toast:bg-destructive/12 group-data-[type=error]/toast:text-destructive",
+ "group-data-[type=warning]/toast:bg-(--toast-warning)/12 group-data-[type=warning]/toast:text-(--toast-warning)",
+ "group-data-[type=info]/toast:bg-(--toast-info)/12 group-data-[type=info]/toast:text-(--toast-info)"
+);
+
+const Toaster = ({ className, ...props }: ToasterProps) => {
+ const { dark } = useTheme();
-const Toaster = ({ theme, ...props }: ToasterProps) => {
- const { dark } = useTheme()
return (
,
- info:
,
- warning:
,
- error:
,
- loading:
,
+ success:
,
+ info:
,
+ warning:
,
+ error:
,
+ loading:
,
+ close:
,
}}
- style={
- {
- '--normal-bg': 'var(--popover)',
- '--normal-text': 'var(--popover-foreground)',
- '--normal-border': 'var(--border)',
- '--border-radius': 'var(--radius)',
- } as React.CSSProperties
- }
+ theme={dark ? "dark" : "light"}
toastOptions={{
+ unstyled: true,
classNames: {
- toast: 'cn-toast',
+ toast: toastClassName,
+ icon: iconClassName,
+ content: "flex min-w-0 flex-1 flex-col gap-0.5",
+ title: "font-medium leading-snug",
+ description: "text-[0.8rem] text-muted-foreground leading-snug",
+ actionButton: buttonVariants({ size: "xs" }),
+ cancelButton: buttonVariants({ size: "xs", variant: "ghost" }),
+ closeButton: cn(
+ buttonVariants({ size: "icon-xs", variant: "outline" }),
+ "absolute -top-2 -left-2 size-5 rounded-full opacity-0 transition-opacity group-focus-within/toast:opacity-100 group-hover/toast:opacity-100 [&_svg]:size-2.5"
+ ),
},
}}
{...props}
/>
- )
-}
+ );
+};
-export { Toaster }
+export { Toaster };
diff --git a/web/src/components/ui/table.tsx b/web/src/components/ui/table.tsx
index 96b63245..738b6656 100644
--- a/web/src/components/ui/table.tsx
+++ b/web/src/components/ui/table.tsx
@@ -1,105 +1,110 @@
-import * as React from 'react'
+"use client";
-import { cn } from '@/lib/utils'
+import { cn } from "@/lib/utils";
+import type * as React from "react";
-function Table({ className, ...props }: React.ComponentProps<'table'>) {
+/**
+ * Dualtone chrome for a single `
`: the header sits on the grey shell,
+ * the body cells form the lifted white card. `border-separate` is required
+ * because row borders and radii only render on cells in that model.
+ */
+const TABLE_CARD_CELLS_CLASS = [
+ "[&>tbody>tr>td]:bg-background [&>tbody>tr>td]:border-border/60 [&>tbody>tr>td]:border-b [&>tbody>tr>td]:transition-colors",
+ "[&>tbody>tr:hover>td]:bg-[color-mix(in_oklab,var(--muted)_50%,var(--background))]",
+ "[&>tbody>tr:first-child>td]:border-t-border [&>tbody>tr:first-child>td]:border-t",
+ "[&>tbody>tr:last-child>td]:border-b-border",
+ "[&>tbody>tr>td:first-child]:border-l-border [&>tbody>tr>td:first-child]:border-l",
+ "[&>tbody>tr>td:last-child]:border-r-border [&>tbody>tr>td:last-child]:border-r",
+ "[&>tbody>tr:first-child>td:first-child]:rounded-tl-[14px] [&>tbody>tr:first-child>td:last-child]:rounded-tr-[14px]",
+ "[&>tbody>tr:last-child>td:first-child]:rounded-bl-[14px] [&>tbody>tr:last-child>td:last-child]:rounded-br-[14px]",
+].join(" ");
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
- )
+ );
}
-function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
- return (
-
- )
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return ;
}
-function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
- return (
-
- )
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return ;
}
-function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
tr]:last:border-b-0',
- className,
- )}
{...props}
/>
- )
+ );
}
-function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
- )
+ );
}
-function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
|
- )
+ );
}
-function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
|
- )
+ );
}
function TableCaption({
className,
...props
-}: React.ComponentProps<'caption'>) {
+}: React.ComponentProps<"caption">) {
return (
- )
+ );
}
export {
@@ -111,4 +116,4 @@ export {
TableRow,
TableCell,
TableCaption,
-}
+};
diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx
index c6f5cefc..d63868cf 100644
--- a/web/src/components/ui/tooltip.tsx
+++ b/web/src/components/ui/tooltip.tsx
@@ -1,57 +1,285 @@
-'use client'
+"use client";
-import * as React from 'react'
-import { Tooltip as TooltipPrimitive } from 'radix-ui'
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
+import { cn } from "@/lib/utils";
+import {
+ createContext,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+ use,
+ useLayoutEffect,
+ useMemo,
+ useState,
+} from "react";
-import { cn } from '@/lib/utils'
+type TooltipPlacement = Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+>;
+
+type TooltipContentProps = TooltipPrimitive.Popup.Props & TooltipPlacement;
+
+type SharedPopupProps = Omit<
+ TooltipPrimitive.Popup.Props,
+ "children" | "className"
+>;
+
+interface SharedTooltipPayload extends TooltipPlacement {
+ children: ReactNode;
+ className?: string;
+ popupProps?: SharedPopupProps;
+}
+
+interface SharedTooltipItem {
+ payload: SharedTooltipPayload | undefined;
+ setPayload: Dispatch>;
+}
+
+const tooltipContentClassName =
+ "border-foreground/60 to-foreground text-background z-50 block w-fit max-w-xs origin-(--transform-origin) rounded-[0.625rem] border bg-linear-to-b from-[color-mix(in_oklab,var(--color-foreground),var(--color-background)_10%)] px-2.5 py-1 text-xs shadow-[inset_0_1px_0_rgba(255,255,255,0.22),0_1px_2px_rgba(0,0,0,0.16),0_6px_16px_-4px_rgba(0,0,0,0.24)] transition-[opacity,scale,translate] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] [corner-shape:squircle] has-[[data-slot=kbd]]:pr-1 data-ending-style:scale-[0.97] data-ending-style:opacity-0 data-ending-style:duration-100 data-starting-style:scale-[0.97] data-starting-style:opacity-0 data-[side=bottom]:data-starting-style:-translate-y-0.5 data-[side=left]:data-starting-style:translate-x-0.5 data-[side=right]:data-starting-style:-translate-x-0.5 data-[side=top]:data-starting-style:translate-y-0.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm **:data-[slot=kbd]:align-middle **:data-[slot=kbd-group]:ms-1.5 **:data-[slot=kbd-group]:align-middle *:data-[slot=kbd]:ms-1.5 [--tooltip-arrow:var(--color-foreground)] **:[--color-border:color-mix(in_oklab,var(--color-background)_18%,transparent)] **:[--color-foreground:var(--color-background)] **:[--color-muted-foreground:color-mix(in_oklab,var(--color-background)_65%,transparent)] motion-reduce:transition-opacity";
+
+const TOOLTIP_ARROW_CLASS_NAME =
+ "bg-(--tooltip-arrow) fill-(--tooltip-arrow) -z-10 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5";
+
+// Inside a TooltipProvider every tooltip shares one popup, so moving between
+// neighbours glides it over and resizes it instead of closing and reopening.
+const SHARED_POSITIONER_CLASS_NAME =
+ "h-(--positioner-height) w-(--positioner-width) transition-[top,left,right,bottom] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none";
+const SHARED_POPUP_CLASS_NAME =
+ "h-(--popup-height) w-(--popup-width) max-w-none p-0 has-[[data-slot=kbd]]:pr-0 transition-[opacity,scale,translate,width,height] motion-reduce:transition-opacity";
+const SHARED_VIEWPORT_CLASS_NAME =
+ "relative m-0! overflow-hidden rounded-[inherit] [&>[data-previous]]:hidden";
+// The label keeps its own natural width, so it never reflows while the popup
+// resizes around it.
+const SHARED_CONTENT_CLASS_NAME =
+ "w-max max-w-xs px-2.5 py-1 has-[[data-slot=kbd]]:pr-1";
+const SHARED_ARROW_CLASS_NAME =
+ "transition-[left,top] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none";
+
+const SharedTooltipContext =
+ createContext | null>(null);
+const SharedTooltipItemContext = createContext(null);
+
+function TooltipFrame({
+ align = "center",
+ alignOffset = 0,
+ children,
+ className,
+ contentClassName,
+ shared = false,
+ side = "top",
+ sideOffset = 4,
+ ...props
+}: TooltipContentProps & { contentClassName?: string; shared?: boolean }) {
+ return (
+
+
+
+ {shared ? (
+
+
+ {children}
+
+
+ ) : (
+ children
+ )}
+
+
+
+
+ );
+}
function TooltipProvider({
- delayDuration = 0,
+ children,
+ delay = 0,
...props
-}: React.ComponentProps) {
+}: TooltipPrimitive.Provider.Props) {
+ // The shared popup only carries labels, so it closes as soon as the pointer
+ // leaves the trigger (disableHoverablePopup on the shared Root).
+ const [handle] = useState(() =>
+ TooltipPrimitive.createHandle()
+ );
+
return (
- )
+ >
+
+ {children}
+
+ {({ payload }) => {
+ const {
+ children: content,
+ className: contentClassName,
+ popupProps,
+ ...frameProps
+ } = payload ?? {};
+ return (
+
+ {content}
+
+ );
+ }}
+
+
+
+ );
}
-function Tooltip({
- ...props
-}: React.ComponentProps) {
- return
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ const handle = use(SharedTooltipContext);
+ const [payload, setPayload] = useState();
+ const item = useMemo(() => ({ payload, setPayload }), [payload]);
+
+ if (handle) {
+ return (
+
+ {props.children as ReactNode}
+
+ );
+ }
+
+ return ;
}
-function TooltipTrigger({
- ...props
-}: React.ComponentProps) {
- return
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ const handle = use(SharedTooltipContext);
+ const item = use(SharedTooltipItemContext);
+
+ if (handle && item) {
+ return (
+
+ );
+ }
+
+ return ;
+}
+
+const isSamePopupProps = (
+ a: SharedPopupProps | undefined,
+ b: SharedPopupProps | undefined
+) => {
+ const aKeys = Object.keys(a ?? {}) as (keyof SharedPopupProps)[];
+ const bKeys = Object.keys(b ?? {});
+ return (
+ aKeys.length === bKeys.length && aKeys.every((key) => a?.[key] === b?.[key])
+ );
+};
+
+const isSamePayload = (
+ a: SharedTooltipPayload | undefined,
+ b: SharedTooltipPayload
+) =>
+ a !== undefined &&
+ a.align === b.align &&
+ a.alignOffset === b.alignOffset &&
+ a.children === b.children &&
+ a.className === b.className &&
+ a.side === b.side &&
+ a.sideOffset === b.sideOffset &&
+ isSamePopupProps(a.popupProps, b.popupProps);
+
+function SharedTooltipContent({
+ hidden,
+ setPayload,
+ ...payload
+}: SharedTooltipPayload & {
+ hidden?: boolean;
+ setPayload: SharedTooltipItem["setPayload"];
+}) {
+ useLayoutEffect(() => {
+ if (hidden) {
+ setPayload(undefined);
+ return;
+ }
+ // Only store a new payload when something changed, so re-renders of the
+ // item don't loop back into another state update.
+ setPayload((current) =>
+ isSamePayload(current, payload) ? current : payload
+ );
+ });
+
+ useLayoutEffect(() => () => setPayload(undefined), [setPayload]);
+
+ return null;
}
function TooltipContent({
- className,
- sideOffset = 0,
children,
+ className,
...props
-}: React.ComponentProps) {
- return (
-
-
{children}
-
-
-
- )
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
}
-export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+export {
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+ TooltipProvider,
+ tooltipContentClassName,
+};
diff --git a/web/src/index.css b/web/src/index.css
index ef2e3792..a372b424 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -114,6 +114,61 @@
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 4px);
+ --animate-button-swap-in: button-swap-in 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
+ --animate-button-swap-out: button-swap-out 360ms cubic-bezier(0.45, 0, 0.25, 1) both;
+ --animate-button-dot: button-dot 1s ease-in-out infinite;
+ --animate-button-progress-out: button-progress-out 600ms ease-out both;
+ --animate-shimmer: shimmer var(--shimmer-duration, 1.7s) linear infinite;
+ @keyframes button-swap-in {
+ from {
+ opacity: 0;
+ transform: translateY(0.45em);
+ filter: blur(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ filter: blur(0);
+ }
+ }
+ @keyframes button-swap-out {
+ from {
+ opacity: 1;
+ transform: translateY(0);
+ filter: blur(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(-0.45em);
+ filter: blur(4px);
+ }
+ }
+ @keyframes button-dot {
+ 0%, 60%, 100% {
+ opacity: 0.45;
+ transform: translateY(0);
+ }
+ 30% {
+ opacity: 1;
+ transform: translateY(-0.3em);
+ }
+ }
+ @keyframes button-progress-out {
+ 0%, 50% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+ }
+ @keyframes shimmer {
+ from {
+ background-position: 100% 0;
+ }
+ to {
+ background-position: 0% 0;
+ }
+ }
}
/* Non-inline so the overlay shadow resolves through var(--shadow-overlay) at
@@ -313,4 +368,4 @@
body {
@apply bg-background text-foreground font-sans antialiased;
}
-}
+}
\ No newline at end of file
diff --git a/web/src/managed-agents/GitHub.tsx b/web/src/managed-agents/GitHub.tsx
index e403c0ba..0ad17dca 100644
--- a/web/src/managed-agents/GitHub.tsx
+++ b/web/src/managed-agents/GitHub.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { GitBranch, Loader2, Unplug } from 'lucide-react'
+import { AlertTriangle, GitBranch, Loader2, Unplug } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { GithubMark } from '@/components/github-mark'
import {
@@ -139,6 +139,40 @@ export function ManagedProjectGitHub({
Disconnect
+ ) : availableConnections.length === 0 ? (
+ {
+ setAddingConnection(true)
+ void launchAuthorizationWindow(() =>
+ connectManagedGitHub({
+ projectId,
+ environments: [environment],
+ }).then((connection) => connection.installUrl),
+ )
+ .catch((error: unknown) =>
+ notifyError(
+ "Couldn't start the GitHub installation.",
+ error,
+ ),
+ )
+ .finally(() => setAddingConnection(false))
+ }}
+ >
+ {addingConnection ? (
+
+ ) : (
+
+ )}
+ Add GitHub connection
+
+ }
+ />
) : (
@@ -208,7 +242,8 @@ export function ManagedProjectGitHub({
)}
{!status.data?.app ? (
-
+
+
The managed GitHub App is not configured in this environment.
) : null}
diff --git a/web/src/managed-agents/Session.tsx b/web/src/managed-agents/Session.tsx
index de4a8162..37274ccb 100644
--- a/web/src/managed-agents/Session.tsx
+++ b/web/src/managed-agents/Session.tsx
@@ -1,11 +1,13 @@
-import { useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
ArrowLeft,
Bot,
Clock3,
FolderDown,
+ ListTree,
Loader2,
+ MessagesSquare,
TerminalSquare,
} from 'lucide-react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
@@ -20,6 +22,16 @@ import {
PanelTitle,
} from '@/components/panel'
import { StatusBadge } from '@/components/status-badge'
+import {
+ ChatMinimap,
+ ChatMinimapItem,
+} from '@/components/chat-minimap/components/chat-minimap'
+import {
+ IconTabs,
+ IconTabsList,
+ IconTabsTrigger,
+} from '@/components/icon-tabs/components/icon-tabs'
+import { Shimmer } from '@/components/shimmer/components/shimmer'
import { Button } from '@/components/ui/button'
import {
getManagedAgentSession,
@@ -75,6 +87,36 @@ export default function ManagedSessionDetail() {
refetchInterval: 1_000,
})
+ // Scroll-spy for the conversation minimap: the page scrolls, so the
+ // observer uses the viewport and the topmost on-screen turn wins. The
+ // effect depends on activeTab so it re-observes the remounted turn
+ // elements after leaving and returning to the Conversation tab.
+ const turnEls = useRef<(HTMLElement | null)[]>([])
+ const visibleTurns = useRef>(new Set())
+ const [activeTurn, setActiveTurn] = useState(0)
+ const turnCount = session.data?.turns.length ?? 0
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ const index = turnEls.current.indexOf(entry.target as HTMLElement)
+ if (index < 0) continue
+ if (entry.isIntersecting) visibleTurns.current.add(index)
+ else visibleTurns.current.delete(index)
+ }
+ if (visibleTurns.current.size > 0) {
+ setActiveTurn(Math.min(...visibleTurns.current))
+ }
+ },
+ { rootMargin: '-15% 0px -65% 0px' },
+ )
+ visibleTurns.current.clear()
+ for (const el of turnEls.current) {
+ if (el) observer.observe(el)
+ }
+ return () => observer.disconnect()
+ }, [turnCount, activeTab])
+
if (project.isLoading || session.isLoading) {
return (
@@ -199,42 +241,31 @@ export default function ManagedSessionDetail() {
) : null}
-
+ setActiveTab(value as 'conversation' | 'events' | 'files')
+ }
+ className="w-fit"
>
-
-
-
-
+
+ }
+ value="conversation"
+ >
+ Conversation
+
+ } value="events">
+ Events
+
+ {events.data?.length ?? 0}
+
+
+ } value="files">
+ Files
+
+
+
{activeTab === 'conversation' ? (
@@ -246,11 +277,13 @@ export default function ManagedSessionDetail() {
-
+
{session.data.turns.length === 0 ? (
No turns yet.
) : (
- session.data.turns.map((turn) => {
+
+
+ {session.data.turns.map((turn, turnIndex) => {
const responseText = turnAssistantText(
events.data ?? [],
turn.id,
@@ -262,7 +295,13 @@ export default function ManagedSessionDetail() {
const payload = turnPayload(events.data ?? [], turn.id)
const running = !['completed', 'failed'].includes(turn.status)
return (
-
+
{
+ turnEls.current[turnIndex] = el
+ }}
+ className="scroll-mt-24 space-y-6"
+ >
You
@@ -294,7 +333,7 @@ export default function ManagedSessionDetail() {
className="size-3 animate-spin"
aria-hidden
/>
- Streaming response…
+ Streaming response…
) : null}
@@ -313,7 +352,29 @@ export default function ManagedSessionDetail() {
)
- })
+ })}
+
+
+ {session.data.turns.map((turn, turnIndex) => (
+ {
+ setActiveTurn(turnIndex)
+ turnEls.current[turnIndex]?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ })
+ }}
+ />
+ ))}
+
+
)}
diff --git a/web/src/managed-agents/Settings.tsx b/web/src/managed-agents/Settings.tsx
index 34ac6b12..6de35243 100644
--- a/web/src/managed-agents/Settings.tsx
+++ b/web/src/managed-agents/Settings.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
+ AlertTriangle,
ExternalLink,
GitBranch,
GitPullRequest,
@@ -342,40 +343,41 @@ export function ManagedProjectSettings({ projectId }: { projectId: string }) {
) : activeConnections.length === 0 ? (
-
-
- Install the OpenComputer GitHub App on the repository you want
- to deploy from.
-
-
-
+ .then(() =>
+ queryClient.invalidateQueries({
+ queryKey: ['managed-github-connections'],
+ }),
+ )
+ .catch((error: unknown) =>
+ notifyError(
+ "Couldn't start the GitHub installation.",
+ error,
+ ),
+ )
+ .finally(() => setAddingConnection(false))
+ }}
+ >
+ {addingConnection ? (
+
+ ) : (
+
+ )}
+ Add GitHub connection
+
+ }
+ />
) : (