Skip to content
edmi-ui

Task

Collapsible task row with file chips.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Read
weights.ts
Validate total = 100%
Write
weights.ts
Run tests
Deploy preview failed
Read
weights.ts
Validate total = 100%
Write
weights.ts
Run tests
Deploy preview failed
Read weights.ts
Validate total = 100%
Write weights.ts
Run tests
Deploy preview failed

Built on collapsible.

Terminal window
npx shadcn@latest add @edmi-ui/ai-task
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-task
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-task
Terminal window
bunx shadcn@latest add @edmi-ui/ai-task

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-task
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-task
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-task
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-task

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-task.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-task.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-task.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-task.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
Task,
TaskContent,
TaskItem,
TaskItemFile,
TaskTrigger,
} from "@/components/ai/task";
<Task className="w-full max-w-lg">
<TaskTrigger title="Update the weights file · 2 of 5" />
<TaskContent>
<TaskItem status="completed">
Read <TaskItemFile>weights.ts</TaskItemFile>
</TaskItem>
<TaskItem status="completed">Validate total = 100%</TaskItem>
<TaskItem status="in-progress">
Write <TaskItemFile>weights.ts</TaskItemFile>
</TaskItem>
<TaskItem status="pending">Run tests</TaskItem>
<TaskItem status="error">Deploy preview failed</TaskItem>
</TaskContent>
</Task>
<script setup lang="ts">
import {
Task,
TaskContent,
TaskItem,
TaskItemFile,
TaskTrigger,
} from "@/components/ai/task";
</script>
<template>
<Task class="w-full max-w-lg">
<TaskTrigger title="Update the weights file · 2 of 5" />
<TaskContent>
<TaskItem status="completed">Read <TaskItemFile>weights.ts</TaskItemFile></TaskItem>
<TaskItem status="completed">Validate total = 100%</TaskItem>
<TaskItem status="in-progress">Write <TaskItemFile>weights.ts</TaskItemFile></TaskItem>
<TaskItem status="pending">Run tests</TaskItem>
<TaskItem status="error">Deploy preview failed</TaskItem>
</TaskContent>
</Task>
</template>
<script lang="ts">
import {
Task,
TaskContent,
TaskItem,
TaskItemFile,
TaskTrigger,
} from "$lib/components/ai/task";
</script>
<Task class="w-full max-w-lg">
<TaskTrigger title="Update the weights file · 2 of 5" />
<TaskContent>
<TaskItem status="completed">Read <TaskItemFile>weights.ts</TaskItemFile></TaskItem>
<TaskItem status="completed">Validate total = 100%</TaskItem>
<TaskItem status="in-progress">Write <TaskItemFile>weights.ts</TaskItemFile></TaskItem>
<TaskItem status="pending">Run tests</TaskItem>
<TaskItem status="error">Deploy preview failed</TaskItem>
</TaskContent>
</Task>
registry/components/ai/task.tsx
"use client";
import { cn } from "cn";
import type { ComponentProps } from "react";
import { Children } from "react";
import { FileIcon, CircleIcon, SpinnerIcon, CheckCircleIcon, WarningCircleIcon, CaretDownIcon } from "@phosphor-icons/react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/registry/edmi/ui/collapsible";
export type TaskItemFileProps = ComponentProps<"div">;
export const TaskItemFile = ({
children,
className,
...props
}: TaskItemFileProps) => (
<div
data-slot="ai-task-item-file"
className={cn(
"inline-flex h-5 items-center gap-1 rounded-md border border-border bg-secondary px-1.5 font-mono text-[11px] text-secondary-foreground [&_svg]:size-3 [&_svg]:shrink-0",
className,
)}
{...props}
>
<FileIcon
/>
{children}
</div>
);
export type TaskItemStatus = "pending" | "in-progress" | "completed" | "error";
export type TaskItemProps = ComponentProps<"div"> & {
/** ✦ Leading status icon: pending, in-progress (spinner), completed (struck through), error. */
status?: TaskItemStatus;
};
const statusIcon = {
pending: (
<CircleIcon
className="text-muted-foreground-2"
/>
),
"in-progress": (
<SpinnerIcon
className="animate-spin text-info-text"
/>
),
completed: (
<CheckCircleIcon
className="text-success-text"
/>
),
error: (
<WarningCircleIcon
className="text-destructive-text"
/>
),
} as const;
export const TaskItem = ({
children,
className,
status,
...props
}: TaskItemProps) =>
status ? (
<div
data-slot="ai-task-item"
data-status={status}
className={cn(
"flex flex-wrap items-center gap-2 text-[13.5px] [&>svg]:size-4 [&>svg]:shrink-0",
status === "completed" && "text-muted-foreground",
status === "pending" && "text-muted-foreground",
status === "error" && "text-destructive-text",
className,
)}
{...props}
>
{statusIcon[status]}
{/* line-through only the text, not the file chips (decorations propagate into flex items) */}
{status === "completed"
? Children.map(children, (child) =>
typeof child === "string" ? (
<span className="line-through">{child}</span>
) : (
child
),
)
: children}
</div>
) : (
<div
data-slot="ai-task-item"
className={cn("text-[13.5px] text-muted-foreground", className)}
{...props}
>
{children}
</div>
);
export type TaskProps = ComponentProps<typeof Collapsible>;
export const Task = ({
defaultOpen = true,
className,
...props
}: TaskProps) => (
<Collapsible
data-slot="ai-task"
className={cn("not-prose", className)}
defaultOpen={defaultOpen}
{...props}
/>
);
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string;
};
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger
data-slot="ai-task-trigger"
className={cn(
"group/task-trigger flex w-full items-center gap-2 text-[13.5px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className,
)}
{...props}
>
{children ?? (
<>
<CheckCircleIcon
className="size-4"
/>
<span>{title}</span>
<CaretDownIcon
className="ml-auto size-4 transition-transform group-data-[panel-open]/task-trigger:rotate-180"
/>
</>
)}
</CollapsibleTrigger>
);
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
export const TaskContent = ({
children,
className,
...props
}: TaskContentProps) => (
<CollapsibleContent
data-slot="ai-task-content"
className={cn(
"h-(--collapsible-panel-height) overflow-hidden outline-none transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0",
className,
)}
{...props}
>
<div className="space-y-2 pt-2.5 pl-[23px]">{children}</div>
</CollapsibleContent>
);
registry/components/ai/task/Task.vue
<script setup lang="ts">
import type { CollapsibleRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Collapsible } from "@/registry/edmi/ui/collapsible"
interface TaskProps extends /* @vue-ignore */ CollapsibleRootProps {
class?: HTMLAttributes["class"]
}
const props = defineProps<TaskProps>()
</script>
<template>
<!-- Collapsible props (open, update:open) fall through as attrs; defaultOpen is true unless overridden. -->
<Collapsible :default-open="true" data-slot="ai-task" :class="cn('not-prose', props.class)">
<slot />
</Collapsible>
</template>
registry/components/ai/task/TaskContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { CollapsibleContent } from "@/registry/edmi/ui/collapsible"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<CollapsibleContent
data-slot="ai-task-content"
:class="cn(
'overflow-hidden outline-none data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
props.class,
)"
>
<div class="space-y-2 pt-2.5 pl-[23px]">
<slot />
</div>
</CollapsibleContent>
</template>
registry/components/ai/task/TaskItem.vue
<script setup lang="ts">
import type { HTMLAttributes, VNode } from "vue"
import { AlertCircleIcon, CircleCheckIcon, CircleIcon, Loader2Icon } from "@lucide/vue"
import { Text, h, useSlots } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
/** ✦ Leading status icon: pending, in-progress (spinner), completed (struck through), error. */
status?: "pending" | "in-progress" | "completed" | "error"
}>()
const slots = useSlots()
// line-through only the text, not the file chips (decorations propagate into flex items)
function Content() {
const nodes: VNode[] = slots.default?.() ?? []
if (props.status !== "completed")
return nodes
return nodes.map(node =>
node.type === Text && typeof node.children === "string"
? h("span", { class: "line-through" }, node.children)
: node,
)
}
</script>
<template>
<div
v-if="status"
data-slot="ai-task-item"
:data-status="status"
:class="cn(
'flex flex-wrap items-center gap-2 text-[13.5px] [&>svg]:size-4 [&>svg]:shrink-0',
status === 'completed' && 'text-muted-foreground',
status === 'pending' && 'text-muted-foreground',
status === 'error' && 'text-destructive-text',
props.class,
)"
>
<CircleIcon v-if="status === 'pending'" class="text-muted-foreground-2" />
<Loader2Icon v-else-if="status === 'in-progress'" class="animate-spin text-info-text" />
<CircleCheckIcon v-else-if="status === 'completed'" class="text-success-text" />
<AlertCircleIcon v-else class="text-destructive-text" />
<Content />
</div>
<div v-else data-slot="ai-task-item" :class="cn('text-[13.5px] text-muted-foreground', props.class)">
<slot />
</div>
</template>
registry/components/ai/task/TaskItemFile.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { FileIcon } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="ai-task-item-file"
:class="cn(
'inline-flex h-5 items-center gap-1 rounded-md border border-border bg-secondary px-1.5 font-mono text-[11px] text-secondary-foreground [&_svg]:size-3 [&_svg]:shrink-0',
props.class,
)"
>
<FileIcon />
<slot />
</div>
</template>
registry/components/ai/task/TaskTrigger.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ChevronDownIcon, CircleCheckIcon } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
import { CollapsibleTrigger } from "@/registry/edmi/ui/collapsible"
const props = defineProps<{
title: string
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<CollapsibleTrigger
data-slot="ai-task-trigger"
:class="cn(
'group/task-trigger flex w-full items-center gap-2 text-[13.5px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
props.class,
)"
>
<slot>
<CircleCheckIcon class="size-4" />
<span>{{ title }}</span>
<ChevronDownIcon
class="ml-auto size-4 transition-transform group-data-[state=open]/task-trigger:rotate-180"
/>
</slot>
</CollapsibleTrigger>
</template>
registry/components/ai/task/index.ts
export { default as Task } from "./Task.vue";
export { default as TaskContent } from "./TaskContent.vue";
export { default as TaskItem } from "./TaskItem.vue";
export { default as TaskItemFile } from "./TaskItemFile.vue";
export { default as TaskTrigger } from "./TaskTrigger.vue";
src/lib/registry/ai/task/index.ts
import Root from "./task.svelte";
import Content from "./task-content.svelte";
import Item from "./task-item.svelte";
import ItemFile from "./task-item-file.svelte";
import Trigger from "./task-trigger.svelte";
export {
Content,
Content as TaskContent,
Item,
Item as TaskItem,
ItemFile,
ItemFile as TaskItemFile,
Root,
//
Root as Task,
Trigger,
Trigger as TaskTrigger,
};
src/lib/registry/ai/task/task-content.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { CollapsibleContent } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
let {
class: className,
children,
...restProps
}: ComponentProps<typeof CollapsibleContent> = $props();
</script>
<CollapsibleContent
data-slot="ai-task-content"
class={cn(
"overflow-hidden outline-none data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
className
)}
{...restProps}
>
<div class="space-y-2 pt-2.5 pl-[23px]">
{@render children?.()}
</div>
</CollapsibleContent>
src/lib/registry/ai/task/task-item-file.svelte
<script lang="ts">
import FileIcon from "phosphor-svelte/lib/File";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();
</script>
<span
data-slot="ai-task-item-file"
class={cn(
"inline-flex h-5 items-center gap-1 rounded-md border border-border bg-secondary px-1.5 align-middle font-mono text-[11px] text-secondary-foreground [&_svg]:size-3 [&_svg]:shrink-0",
className
)}
{...restProps}
>
<FileIcon
/>
{@render children?.()}
</span>
src/lib/registry/ai/task/task-item.svelte
<script lang="ts">
import CircleIcon from "phosphor-svelte/lib/Circle";
import SpinnerIcon from "phosphor-svelte/lib/Spinner";
import CheckCircleIcon from "phosphor-svelte/lib/CheckCircle";
import WarningCircleIcon from "phosphor-svelte/lib/WarningCircle";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
class: className,
status,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
/** ✦ Leading status icon: pending, in-progress (spinner), completed (struck through), error. */
status?: "pending" | "in-progress" | "completed" | "error";
} = $props();
</script>
{#if status}
<div
data-slot="ai-task-item"
data-status={status}
class={cn(
"flex items-start gap-2 text-[13.5px] [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0",
(status === "completed" || status === "pending") && "text-muted-foreground",
status === "error" && "text-destructive-text",
className
)}
{...restProps}
>
{#if status === "pending"}
<CircleIcon
class="text-muted-foreground-2"
/>
{:else if status === "in-progress"}
<SpinnerIcon
class="animate-spin text-info-text"
/>
{:else if status === "completed"}
<CheckCircleIcon
class="text-success-text"
/>
{:else}
<WarningCircleIcon
class="text-destructive-text"
/>
{/if}
<!-- Inline flow: the line-through reaches the text but not the inline-flex file chips. -->
<span class={cn("min-w-0", status === "completed" && "line-through")}>
{@render children?.()}
</span>
</div>
{:else}
<div data-slot="ai-task-item" class={cn("text-[13.5px] text-muted-foreground", className)} {...restProps}>
{@render children?.()}
</div>
{/if}
src/lib/registry/ai/task/task-trigger.svelte
<script lang="ts">
import CheckCircleIcon from "phosphor-svelte/lib/CheckCircle";
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { cn } from "$lib/utils.js";
import { CollapsibleTrigger } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
let {
class: className,
title,
children,
...restProps
}: Omit<ComponentProps<typeof CollapsibleTrigger>, "title"> & { title: string } = $props();
</script>
<CollapsibleTrigger
data-slot="ai-task-trigger"
class={cn(
"group/task-trigger flex w-full items-center gap-2 text-[13.5px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className
)}
{...restProps}
>
{#if children}
{@render children()}
{:else}
<CheckCircleIcon
class="size-4"
/>
<span>{title}</span>
<CaretDownIcon
class="ml-auto size-4 transition-transform group-data-[state=open]/task-trigger:rotate-180"
/>
{/if}
</CollapsibleTrigger>
src/lib/registry/ai/task/task.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Collapsible } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
let {
class: className,
open = $bindable(true),
children,
...restProps
}: ComponentProps<typeof Collapsible> = $props();
</script>
<Collapsible bind:open data-slot="ai-task" class={cn("not-prose", className)} {...restProps}>
{@render children?.()}
</Collapsible>
Part Prop Type Default
Task open, defaultOpen, onOpenChange collapsible state defaultOpen = true
TaskTrigger title string
TaskItem status pending | in-progress | completed | error none
TaskItemFile mono file chip
  • TaskItem status ✦ adds a leading status icon: spinner while in progress, struck-through text when completed, destructive text on error.
  • TaskItemFile carries a file icon; the chip is never struck through.