mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): session threads feedback (#23945)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { useInfiniteQuery } from "react-query";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { useLocation, useNavigate, useParams } from "react-router";
|
||||
import { infiniteSessionThreads } from "#/api/queries/aiBridge";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
@@ -13,6 +13,7 @@ const SessionThreadsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { entitlements } = useDashboard();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const { isEntitled, isEnabled, hasPermission } = getAIBridgePermissions(
|
||||
entitlements,
|
||||
@@ -45,7 +46,17 @@ const SessionThreadsPage: FC = () => {
|
||||
onFetchNextPage={sessionQuery.fetchNextPage}
|
||||
isAISessionsEnabled={isEnabled}
|
||||
isAISessionsEntitled={isEntitled}
|
||||
onBackClicked={() => navigate(-1)}
|
||||
onBackClicked={() => {
|
||||
// location.key is "default" when the user navigated directly to
|
||||
// this page (e.g. by refreshing or opening in a new tab). if there
|
||||
// is a previous page in the history stack, navigate back. otherwise,
|
||||
// navigate to the sessions list page without params
|
||||
if (location.key === "default") {
|
||||
navigate("/aibridge/sessions");
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { AIBridgeSetupAlert } from "../AIBridgeSetupAlert";
|
||||
import { SessionSummaryTable } from "./SessionSummaryTable";
|
||||
import { SessionTimeline } from "./SessionTimeline/SessionTimeline";
|
||||
import { SessionTimelineSkeleton } from "./SessionTimeline/SessionTimelineSkeleton";
|
||||
|
||||
const SessionSummaryTooltip: FC<PropsWithChildren> = ({ children }) => (
|
||||
<TooltipProvider>
|
||||
@@ -118,7 +119,7 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({
|
||||
)}
|
||||
</aside>
|
||||
<main className="flex-1 min-w-0">
|
||||
{session && (
|
||||
{session ? (
|
||||
<SessionTimeline
|
||||
initiator={session.initiator}
|
||||
threads={threads}
|
||||
@@ -126,6 +127,8 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onFetchNextPage={onFetchNextPage}
|
||||
/>
|
||||
) : (
|
||||
loading && <SessionTimelineSkeleton />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
-8
@@ -13,8 +13,6 @@ export const Short: Story = {
|
||||
args: {
|
||||
duration: 4_200,
|
||||
toolCalls: 3,
|
||||
inputTokens: 1234,
|
||||
outputTokens: 567,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,8 +20,6 @@ export const Long: Story = {
|
||||
args: {
|
||||
duration: 125_000,
|
||||
toolCalls: 42,
|
||||
inputTokens: 150_000,
|
||||
outputTokens: 12_000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,8 +27,6 @@ export const SingleToolCall: Story = {
|
||||
args: {
|
||||
duration: 980,
|
||||
toolCalls: 1,
|
||||
inputTokens: 320,
|
||||
outputTokens: 88,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,7 +34,5 @@ export const NoToolCalls: Story = {
|
||||
args: {
|
||||
duration: 500,
|
||||
toolCalls: 0,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import type { FC } from "react";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { TokenBadges } from "../../TokenBadges";
|
||||
import { roundDurationDisplay } from "../../utils";
|
||||
|
||||
interface AgenticLoopTableProps {
|
||||
duration: number; // in seconds
|
||||
toolCalls: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AgenticLoopTable: FC<AgenticLoopTableProps> = ({
|
||||
duration,
|
||||
toolCalls,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
@@ -25,10 +20,6 @@ export const AgenticLoopTable: FC<AgenticLoopTableProps> = ({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between h-6">
|
||||
<span className="pr-4">In / out tokens</span>
|
||||
<TokenBadges inputTokens={inputTokens} outputTokens={outputTokens} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between h-6">
|
||||
<span className="pr-4">Tool calls</span>
|
||||
<span>{toolCalls}</span>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const PromptTable: FC<PromptTableProps> = ({
|
||||
className="text-right flex items-center justify-end"
|
||||
title={formatDate(timestamp)}
|
||||
>
|
||||
<span className="block font-mono whitespace-nowrap truncate">
|
||||
<span className="block font-mono text-xs whitespace-nowrap truncate">
|
||||
{formatDate(timestamp)}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
+71
-56
@@ -30,14 +30,17 @@ import { AgenticLoopTable } from "./AgenticLoopTable";
|
||||
import { PromptTable } from "./PromptTable";
|
||||
import { ToolCallTable } from "./ToolCallTable";
|
||||
|
||||
const EXPANDABLE_COLLAPSE_HEIGHT = 50;
|
||||
|
||||
interface ExpandableTextProps {
|
||||
maxHeight: number;
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ExpandableText: FC<ExpandableTextProps> = ({ text, className }) => {
|
||||
const ExpandableText: FC<ExpandableTextProps> = ({
|
||||
maxHeight,
|
||||
text,
|
||||
className,
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLParagraphElement>(null);
|
||||
const [isExpandable, setIsExpandable] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
@@ -45,8 +48,8 @@ const ExpandableText: FC<ExpandableTextProps> = ({ text, className }) => {
|
||||
useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
setIsExpandable(el.scrollHeight > EXPANDABLE_COLLAPSE_HEIGHT);
|
||||
}, []);
|
||||
setIsExpandable(el.scrollHeight > maxHeight);
|
||||
}, [maxHeight]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -55,11 +58,11 @@ const ExpandableText: FC<ExpandableTextProps> = ({ text, className }) => {
|
||||
style={
|
||||
isExpandable && !isExpanded
|
||||
? {
|
||||
maxHeight: EXPANDABLE_COLLAPSE_HEIGHT,
|
||||
maxHeight,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(className, "overflow-hidden", isExpanded && "pb-9")}
|
||||
className={cn(className, "overflow-scroll", isExpanded && "pb-9")}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
@@ -161,6 +164,7 @@ const ThinkingBlock: FC<ThinkingBlockProps> = ({ text }) => (
|
||||
<span className="font-mono ml-2 text-xs">Thinking...</span>
|
||||
</div>
|
||||
<ExpandableText
|
||||
maxHeight={50}
|
||||
text={text}
|
||||
className="text-sm text-pretty font-normal m-0"
|
||||
/>
|
||||
@@ -210,8 +214,11 @@ const ToolCallBlock: FC<ToolCallBlockProps> = ({
|
||||
outputTokens={outputTokens}
|
||||
tokenUsageMetadata={tokenUsageMetadata}
|
||||
/>
|
||||
<pre className="bg-surface-secondary rounded-md m-4 p-4 text-sm font-mono text-content-primary overflow-x-auto m-0">
|
||||
{tool} <JsonPrettyPrinter input={input} />
|
||||
<pre className="flex gap-4 bg-surface-secondary rounded-md m-4 p-4 text-sm font-mono text-content-primary overflow-x-auto m-0">
|
||||
<span>{tool}</span>
|
||||
<span>
|
||||
<JsonPrettyPrinter input={input} />
|
||||
</span>
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
@@ -298,7 +305,9 @@ const ThreadItem: FC<ThreadItemProps> = ({ thread, initiator }) => {
|
||||
new Date(thread.ended_at ?? Date.now()).getTime() -
|
||||
new Date(thread.started_at).getTime();
|
||||
|
||||
const toolCalls = thread.agentic_actions?.reduce(
|
||||
const hasAgenticLoop = thread.agentic_actions.length > 0;
|
||||
|
||||
const toolCalls = thread.agentic_actions.reduce(
|
||||
(count, action) => count + action.tool_calls.length,
|
||||
0,
|
||||
);
|
||||
@@ -320,7 +329,7 @@ const ThreadItem: FC<ThreadItemProps> = ({ thread, initiator }) => {
|
||||
</div>
|
||||
|
||||
{/* center column: prompt */}
|
||||
<div className="flex-grow flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 mb-2 min-w-0 flex-1 w-full">
|
||||
{thread.prompt && (
|
||||
<>
|
||||
<div className="text-sm text-content-secondary font-normal my-1 flex items-center gap-1">
|
||||
@@ -352,9 +361,11 @@ const ThreadItem: FC<ThreadItemProps> = ({ thread, initiator }) => {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<p className="text-sm text-content-secondary font-normal bg-surface-secondary leading-relaxed rounded-md p-3 overflow-auto m-0 text-pretty">
|
||||
{thread.prompt}
|
||||
</p>
|
||||
<ExpandableText
|
||||
maxHeight={200}
|
||||
text={thread.prompt}
|
||||
className="text-sm text-content-secondary font-normal bg-surface-secondary leading-relaxed rounded-md p-3 m-0 text-pretty"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -369,50 +380,54 @@ const ThreadItem: FC<ThreadItemProps> = ({ thread, initiator }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BracketConnector
|
||||
firstRowHeight="60px"
|
||||
contentClassName="border border-dashed rounded-md my-4"
|
||||
>
|
||||
{/* Agentic loop */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between">
|
||||
<div>
|
||||
<CollapseButton
|
||||
isOpen={agenticLoopOpen}
|
||||
onClick={() => setAgenticLoopOpen(!agenticLoopOpen)}
|
||||
>
|
||||
<span className="text-sm font-normal">Agentic loop</span>
|
||||
</CollapseButton>
|
||||
</div>
|
||||
|
||||
<AgenticLoopTable
|
||||
className="lg:max-w-64 flex-1 my-3 mx-2"
|
||||
duration={durationInMs}
|
||||
toolCalls={toolCalls}
|
||||
inputTokens={thread.token_usage.input_tokens}
|
||||
outputTokens={thread.token_usage.output_tokens}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{agenticLoopOpen && (
|
||||
<>
|
||||
{/* the little top rounded line above the thinking block */}
|
||||
<div className="border-0 border-t border-r border-solid rounded-tr-lg w-[calc(1rem+1px)] h-[20px]">
|
||||
{/* we need the 1px extra to line up with the left border on the other lines */}
|
||||
{hasAgenticLoop ? (
|
||||
<BracketConnector
|
||||
firstRowHeight="60px"
|
||||
contentClassName="border border-dashed rounded-md my-4"
|
||||
>
|
||||
{/* Agentic loop */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between">
|
||||
<div>
|
||||
<CollapseButton
|
||||
isOpen={agenticLoopOpen}
|
||||
onClick={() => setAgenticLoopOpen(!agenticLoopOpen)}
|
||||
>
|
||||
<span className="text-sm font-normal">Agentic loop</span>
|
||||
</CollapseButton>
|
||||
</div>
|
||||
|
||||
{/* Agentic actions */}
|
||||
{thread.agentic_actions?.map((action, i) => (
|
||||
<AgenticActionItem key={`${thread.id}-${i}`} action={action} />
|
||||
))}
|
||||
|
||||
{/* Agentic loop completed block */}
|
||||
<AgenticLoopCompletedBlock
|
||||
inputTokens={thread.token_usage.input_tokens}
|
||||
outputTokens={thread.token_usage.output_tokens}
|
||||
<AgenticLoopTable
|
||||
className="lg:max-w-64 flex-1 my-3 mx-2"
|
||||
duration={durationInMs}
|
||||
toolCalls={toolCalls}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</BracketConnector>
|
||||
</div>
|
||||
|
||||
{agenticLoopOpen && (
|
||||
<>
|
||||
{/* the little top rounded line above the thinking block */}
|
||||
<div className="border-0 border-t border-r border-solid rounded-tr-lg w-[calc(1rem+1px)] h-[20px]">
|
||||
{/* we need the 1px extra to line up with the left border on the other lines */}
|
||||
</div>
|
||||
|
||||
{/* Agentic actions */}
|
||||
{thread.agentic_actions?.map((action, i) => (
|
||||
<AgenticActionItem key={`${thread.id}-${i}`} action={action} />
|
||||
))}
|
||||
|
||||
{/* Agentic loop completed block */}
|
||||
<AgenticLoopCompletedBlock
|
||||
inputTokens={thread.token_usage.input_tokens}
|
||||
outputTokens={thread.token_usage.output_tokens}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</BracketConnector>
|
||||
) : (
|
||||
// if no agentic loop, we need a little spacing element to create
|
||||
// the visual gap between threads
|
||||
<div className="h-4" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -524,7 +539,7 @@ export const SessionTimeline: FC<SessionTimelineProps> = ({
|
||||
<div className="row-start-4 col-start-3 border-0 border-l border-solid">
|
||||
{/* vertical line */}
|
||||
</div>
|
||||
<div className="row-start-4 col-start-4 border-0 border-t border-dashed">
|
||||
<div className="row-start-4 col-start-4 border-0 border-t border-dashed border-surface-green">
|
||||
{/* horizontal border */}
|
||||
</div>
|
||||
<div className="row-start-4 col-start-6 border-0 border-r border-t border-dashed border-surface-green rounded-tr-lg size-4">
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SessionTimelineSkeleton } from "./SessionTimelineSkeleton";
|
||||
|
||||
const meta: Meta<typeof SessionTimelineSkeleton> = {
|
||||
title: "pages/AIBridgePage/SessionTimeline/SessionTimelineSkeleton",
|
||||
component: SessionTimelineSkeleton,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SessionTimelineSkeleton>;
|
||||
|
||||
export const Default: Story = {};
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import type { FC } from "react";
|
||||
import { Skeleton } from "#/components/Skeleton/Skeleton";
|
||||
import { StatusIndicatorDot } from "#/components/StatusIndicator/StatusIndicator";
|
||||
|
||||
export const SessionTimelineSkeleton: FC = () => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-[16px_1rem_1px_1fr_auto_16px]">
|
||||
{/* row 1: session start */}
|
||||
<div className="row-start-1 col-start-2 relative h-10 py-1">
|
||||
<StatusIndicatorDot
|
||||
variant="inactive"
|
||||
className="absolute right-0 translate-x-1/2 translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
<div className="row-start-1 col-start-4 col-span-2 flex items-center h-10">
|
||||
<span className="text-content-secondary font-normal ml-4 py-1 text-sm">
|
||||
Session started
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* row 2: vertical line */}
|
||||
<div className="row-start-2 col-start-3 border-0 border-l border-solid" />
|
||||
|
||||
{/* row 3: space above timeline border */}
|
||||
<div className="row-start-3 col-start-3 border-0 border-l border-t border-solid h-6" />
|
||||
|
||||
{/* row 4: top border */}
|
||||
<div className="row-start-4 col-start-1 border-0 border-l border-t border-dashed border-surface-green rounded-tl-lg size-4" />
|
||||
<div className="row-start-4 col-start-2 border-0 border-t border-dashed border-surface-green" />
|
||||
<div className="row-start-4 col-start-3 border-0 border-l border-solid" />
|
||||
<div className="row-start-4 col-start-4 border-0 border-t border-dashed border-surface-green" />
|
||||
<div className="row-start-4 col-start-6 border-0 border-r border-t border-dashed border-surface-green rounded-tr-lg size-4" />
|
||||
|
||||
{/* row 5: skeleton thread cards */}
|
||||
<div className="row-start-5 col-start-1 border-0 border-l border-dashed border-surface-green" />
|
||||
<div className="row-start-5 col-start-2 col-span-4 flex flex-col gap-4 py-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border border-solid rounded-md flex flex-col lg:flex-row gap-6 p-2"
|
||||
>
|
||||
{/* avatar + username */}
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Skeleton className="size-6 rounded-full flex-shrink-0" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
{/* prompt */}
|
||||
<div className="flex-grow flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
{/* right-column details */}
|
||||
<div className="flex flex-col gap-2 lg:w-64 flex-shrink-0">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-4/5" />
|
||||
<Skeleton className="h-3 w-3/5" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="row-start-5 col-start-6 border-0 border-r border-dashed border-surface-green" />
|
||||
|
||||
{/* row 6: bottom border */}
|
||||
<div className="row-start-6 col-start-1 border-0 border-l border-b border-dashed border-surface-green rounded-bl-lg size-4" />
|
||||
<div className="row-start-6 col-start-2 border-0 border-b border-dashed border-surface-green" />
|
||||
<div className="row-start-6 col-start-3 border-0 border-l border-solid" />
|
||||
<div className="row-start-6 col-start-4 col-span-2 border-0 border-b border-dashed border-surface-green" />
|
||||
<div className="row-start-6 col-start-6 border-0 border-r border-b border-dashed border-surface-green rounded-br-lg size-4" />
|
||||
|
||||
{/* row 7: space below timeline border */}
|
||||
<div className="row-start-7 col-start-3 border-0 border-l border-t border-solid h-4" />
|
||||
|
||||
{/* row 8: session end placeholder */}
|
||||
<div className="row-start-8 col-start-2 relative">
|
||||
<Skeleton className="size-2 rounded-full absolute right-0 translate-x-1/2 translate-y-1/2" />
|
||||
</div>
|
||||
<div className="row-start-8 col-start-4 flex items-center">
|
||||
<Skeleton className="h-4 w-32 ml-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -39,7 +39,7 @@ export const ToolCallTable: FC<ToolCallTableProps> = ({
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="pr-4">Started at</span>
|
||||
<span
|
||||
className="font-mono whitespace-nowrap truncate"
|
||||
className="font-mono text-xs whitespace-nowrap truncate"
|
||||
title={formatDate(timestamp)}
|
||||
>
|
||||
{formatDate(timestamp)}
|
||||
|
||||
Reference in New Issue
Block a user