Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions frontend/pluto_duck_frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,27 @@
animation: stepIn 0.4s cubic-bezier(0.23, 1, 0.32, 1) forwards;
}

@keyframes cardIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

.animate-card-in {
animation: cardIn 0.5s cubic-bezier(0.23, 1, 0.32, 1) forwards;
}

@media (prefers-reduced-motion: reduce) {
.animate-card-in {
animation: none !important;
}
}

.animate-text-glow {
animation: textGlow 2.2s ease-in-out infinite;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use client";

import { cn } from "@/lib/utils";
import { memo, type ComponentProps } from "react";
import {
getTodoCheckboxContainerClass,
IN_PROGRESS_TODO_GLYPH,
type TodoCheckboxStatus,
} from "./todoCheckboxModel";

export type TodoCheckboxProps = ComponentProps<"span"> & {
status?: TodoCheckboxStatus;
};

export const TodoCheckbox = memo(
({ status = "pending", className, ...props }: TodoCheckboxProps) => (
<span
aria-hidden
className={cn(
"inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-[3px] transition-all duration-300",
getTodoCheckboxContainerClass(status),
className
)}
{...props}
>
{status === "in_progress" ? (
<span className="text-[9px] leading-none text-muted-foreground">
{IN_PROGRESS_TODO_GLYPH}
</span>
) : null}
{status === "completed" ? (
<svg
className="h-2.5 w-2.5"
fill="none"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 5.4L4.1 7.4L8 3.2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
/>
</svg>
) : null}
</span>
)
);

TodoCheckbox.displayName = "TodoCheckbox";
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type TodoCheckboxStatus = "pending" | "in_progress" | "completed";

export const IN_PROGRESS_TODO_GLYPH = "\u2734\uFE0E";

export function getTodoCheckboxContainerClass(
status: TodoCheckboxStatus
): string {
if (status === "completed") {
return "border-0 bg-muted-foreground/60 text-white";
}
if (status === "in_progress") {
return "border-[1.5px] border-muted-foreground/50 bg-transparent";
}
return "border-[1.5px] border-muted-foreground/30 bg-transparent";
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ export const ApprovalRenderer = memo(function ApprovalRenderer({
const isPending = item.decision === 'pending';

return (
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="animate-card-in rounded-2xl border border-border bg-card px-5 py-4">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">Approval</span>
<span className="text-[0.88rem] font-semibold">Approval</span>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium',
Expand All @@ -40,20 +40,22 @@ export const ApprovalRenderer = memo(function ApprovalRenderer({
</span>
</div>

<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{summary}</p>
<p className="whitespace-pre-wrap break-words text-[0.8rem] leading-relaxed text-muted-foreground">
{summary}
</p>

{isPending && (
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
className="inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium hover:bg-muted"
className="inline-flex items-center rounded-[10px] border border-border px-4 py-2 text-[0.8rem] font-medium hover:bg-muted"
onClick={() => dispatchApprovalDecision(onDecision, item.id, item.runId, 'rejected')}
>
Reject
</button>
<button
type="button"
className="inline-flex h-8 items-center rounded-md bg-foreground px-3 text-xs font-medium text-background hover:bg-foreground/90"
className="inline-flex items-center rounded-[10px] bg-foreground px-4 py-2 text-[0.8rem] font-medium text-background transition-transform hover:bg-foreground/90 active:scale-[0.97]"
onClick={() => dispatchApprovalDecision(onDecision, item.id, item.runId, 'approved')}
>
Approve
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
'use client';

import { memo } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from '../../ai-elements/tool';
import { StepDot } from '../../ai-elements/step-dot';
import { TodoCheckbox } from '../../ai-elements/todo-checkbox';
import {
Queue,
QueueList,
QueueItem,
QueueItemIndicator,
QueueItemContent,
} from '../../ai-elements/queue';
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '../../ui/collapsible';
import type { ToolItem } from '../../../types/chatRenderItem';
import { parseTodosFromToolPayload } from './toolTodoParser';
import {
getToolTodoStepPhase,
getToolTodoTextClass,
shouldShowToolTodoChevron,
} from './toolTodoViewModel';

/**
* Convert snake_case or camelCase to Title Case
Expand Down Expand Up @@ -94,23 +100,31 @@ function shortenParam(value: string, fieldType: string): string {
/**
* Extract actual content from ToolMessage wrapper
*/
function extractContent(output: any): any {
if (!output) return null;
function extractContent(output: unknown): unknown {
if (output == null) return null;
// Handle ToolMessage wrapper: { type: "tool", content: "...", tool_call_id: "..." }
if (typeof output === 'object' && output.content !== undefined) {
return output.content;
if (typeof output === 'object') {
const toolMessage = output as { content?: unknown };
if (toolMessage.content !== undefined) {
return toolMessage.content;
}
}
return output;
}

/**
* Get first meaningful item from tool output for preview
*/
function getFirstMeaningfulItem(output: any): string | null {
function getFirstMeaningfulItem(output: unknown): string | null {
const content = extractContent(output);
if (!content) return null;
if (content == null) return null;

const str = typeof content === 'string' ? content : JSON.stringify(content);
const serializedContent =
typeof content === 'string' ? content : JSON.stringify(content);
if (typeof serializedContent !== 'string' || !serializedContent.trim()) {
return null;
}
const str = serializedContent;

// Array format: try to parse and get first element
if (str.startsWith('[')) {
Expand Down Expand Up @@ -154,38 +168,44 @@ export const ToolRenderer = memo(function ToolRenderer({
// Special handling for write_todos tool
if (item.toolName === 'write_todos') {
const todos = parseTodosFromToolPayload(item.input, item.output);
const completedCount = todos.filter(t => t.status === 'completed').length;
const isLoading = item.state === 'pending';
const showChevron = shouldShowToolTodoChevron(item.state);

return (
<Queue>
<div className="flex items-center justify-between text-sm text-muted-foreground px-1">
<span className="font-medium">
{isLoading ? 'Updating todos...' : `Tasks (${completedCount}/${todos.length})`}
<Collapsible className="not-prose text-xs group" defaultOpen={true}>
<CollapsibleTrigger
className="group/step flex w-full items-center gap-2.5 rounded-[10px] px-2 py-2 pr-3 transition-colors hover:bg-muted/50 disabled:cursor-default"
disabled={!showChevron}
>
<StepDot phase={getToolTodoStepPhase(item.state)} />
<span className="font-semibold text-[0.85rem] shrink-0">
Update Todos
</span>
</div>
{showChevron ? (
<ChevronDownIcon className="size-3 text-muted-foreground opacity-40 transition-[opacity,transform] shrink-0 ml-auto group-hover/step:opacity-70 group-data-[state=open]/step:rotate-180 group-data-[state=open]/step:opacity-70" />
) : null}
</CollapsibleTrigger>
{todos.length > 0 && (
<QueueList>
{todos.map((todo) => (
<QueueItem key={todo.id} className="flex-row items-start gap-2">
<QueueItemIndicator
completed={todo.status === 'completed'}
inProgress={todo.status === 'in_progress'}
/>
<QueueItemContent
completed={todo.status === 'completed'}
inProgress={todo.status === 'in_progress'}
>
{todo.title}
</QueueItemContent>
</QueueItem>
))}
</QueueList>
<CollapsibleContent className="overflow-hidden text-popover-foreground outline-none data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pl-[38px] pr-2 pb-2">
{todos.map((todo) => (
Comment on lines +188 to +190

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore height cap for write_todos list content

The migrated write_todos view renders todos inside a plain <div> without any max-height/scroll container, so large todo batches (or very long titles) now expand the tool row indefinitely and can push the rest of the chat off-screen; this is a regression from the previous QueueList behavior that constrained height. Because this branch also disables collapsing while pending, users cannot mitigate oversized pending updates.

Useful? React with 👍 / 👎.

<div key={todo.id} className="flex items-start gap-2 py-1">
<TodoCheckbox status={todo.status} />
<span
className={`text-[0.8rem] break-words ${getToolTodoTextClass(todo.status)}`}
>
{todo.title}
</span>
</div>
))}
</div>
</CollapsibleContent>
)}
{item.error && (
<div className="text-xs text-destructive px-1">{item.error}</div>
<div className="pl-[38px] pr-2 pb-2 text-xs text-destructive">
{item.error}
</div>
)}
</Queue>
</Collapsible>
);
}

Expand All @@ -195,6 +215,9 @@ export const ToolRenderer = memo(function ToolRenderer({
const keyParam = extractKeyParam(item.input);
const preview = item.state !== 'pending' ? getFirstMeaningfulItem(item.output) : null;
const actualOutput = extractContent(item.output);
const hasInput = item.input !== null && item.input !== undefined;
const hasOutput = actualOutput !== null && actualOutput !== undefined;
const hasError = Boolean(item.error);

return (
<Tool defaultOpen={false}>
Expand All @@ -205,10 +228,10 @@ export const ToolRenderer = memo(function ToolRenderer({
preview={preview}
/>
<ToolContent>
{item.input && <ToolInput input={item.input} />}
{(actualOutput || item.error) && (
{hasInput && <ToolInput input={item.input} />}
{(hasOutput || hasError) && (
<ToolOutput
output={actualOutput ? JSON.stringify(actualOutput, null, 2) : undefined}
output={hasOutput ? JSON.stringify(actualOutput, null, 2) : undefined}
errorText={item.error}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,5 @@ test('removes extra bottom spacing when next item is not reasoning/tool', () =>
});

test('keeps approval spacing unchanged', () => {
assert.equal(getChatItemPadding(buildItem('approval'), undefined), 'pl-2 pr-2 pt-2 pb-4');
assert.equal(getChatItemPadding(buildItem('approval'), undefined), 'pl-2 pr-2 pt-3 pb-4');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
getTodoCheckboxContainerClass,
IN_PROGRESS_TODO_GLYPH,
} from '../../../ai-elements/todoCheckboxModel.ts';

test('completed todo checkbox has no border class', () => {
const cls = getTodoCheckboxContainerClass('completed');
assert.equal(cls.includes('border-0'), true);
assert.equal(cls.includes('border-[1.5px]'), false);
});

test('in-progress todo checkbox uses border + eight pointed black star', () => {
const cls = getTodoCheckboxContainerClass('in_progress');
assert.equal(cls.includes('border-[1.5px]'), true);
assert.equal(IN_PROGRESS_TODO_GLYPH, '✴︎');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
getToolTodoStepPhase,
getToolTodoTextClass,
shouldShowToolTodoChevron,
} from '../toolTodoViewModel.ts';

test('maps tool todo state to step dot phase', () => {
assert.equal(getToolTodoStepPhase('pending'), 'running');
assert.equal(getToolTodoStepPhase('completed'), 'complete');
assert.equal(getToolTodoStepPhase('error'), 'error');
});

test('shows chevron only when tool todo state is completed', () => {
assert.equal(shouldShowToolTodoChevron('pending'), false);
assert.equal(shouldShowToolTodoChevron('completed'), true);
assert.equal(shouldShowToolTodoChevron('error'), false);
});

test('returns text classes by todo item status', () => {
assert.equal(getToolTodoTextClass('pending'), 'text-muted-foreground');
assert.equal(getToolTodoTextClass('in_progress'), 'text-foreground');
assert.equal(
getToolTodoTextClass('completed'),
'text-muted-foreground line-through'
);
assert.equal(getToolTodoTextClass(undefined), 'text-muted-foreground');
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ export function shouldShowAssistantActions(
export function getAssistantActionsClassName(
params: AssistantActionPolicyParams
): string | null {
// Remove this class tuple only when actions entry motion/spacing is redesigned and tests are updated.
return shouldShowAssistantActions(params) ? "mt-2 animate-step-in" : null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function getChatItemPadding(
case 'assistant-message':
return 'pl-[14px] pr-3 pt-1 pb-3';
case 'approval':
return 'pl-2 pr-2 pt-2 pb-4';
return 'pl-2 pr-2 pt-3 pb-4';
default:
return assertNever(item);
}
Expand Down
Loading