@iloveagents/foundry-web-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/AGENTS.md +59 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +21 -0
  4. package/README.md +41 -0
  5. package/package.json +61 -0
  6. package/src/__tests__/no-spaces-imports.test.ts +59 -0
  7. package/src/__tests__/open-core-guards.test.ts +307 -0
  8. package/src/__tests__/theme-runtime.test.ts +81 -0
  9. package/src/__tests__/tool-fallback.test.tsx +109 -0
  10. package/src/components/ag-ui-runtime-provider.tsx +59 -0
  11. package/src/components/app-brand.tsx +38 -0
  12. package/src/components/assistant-chat.tsx +423 -0
  13. package/src/components/chat-attachments.tsx +68 -0
  14. package/src/components/chat-bubble.tsx +339 -0
  15. package/src/components/chat-header.tsx +44 -0
  16. package/src/components/client-tool-executor.tsx +230 -0
  17. package/src/components/collection-empty-state.tsx +37 -0
  18. package/src/components/collection-filter-bar.tsx +168 -0
  19. package/src/components/collection-search-input.tsx +41 -0
  20. package/src/components/collection-skeleton.tsx +55 -0
  21. package/src/components/collection-sort-menu.tsx +62 -0
  22. package/src/components/collection-surface.tsx +46 -0
  23. package/src/components/collection-toolbar.tsx +33 -0
  24. package/src/components/collection-view-toggle.tsx +61 -0
  25. package/src/components/confirm-dialog.tsx +49 -0
  26. package/src/components/confirmation-card.tsx +32 -0
  27. package/src/components/context-badges.tsx +124 -0
  28. package/src/components/context-bar.tsx +202 -0
  29. package/src/components/form-dialog.tsx +56 -0
  30. package/src/components/global-selection-popover.tsx +135 -0
  31. package/src/components/infinite-scroll-sentinel.tsx +48 -0
  32. package/src/components/json-viewer.tsx +232 -0
  33. package/src/components/loading-indicator.tsx +40 -0
  34. package/src/components/markdown-text.tsx +379 -0
  35. package/src/components/name-dialog.tsx +87 -0
  36. package/src/components/selection-popover.tsx +156 -0
  37. package/src/components/show-document-tool-ui.tsx +90 -0
  38. package/src/components/sidebar.test.tsx +182 -0
  39. package/src/components/sidebar.tsx +1174 -0
  40. package/src/components/surface-card.tsx +45 -0
  41. package/src/components/theme-runtime-provider.tsx +93 -0
  42. package/src/components/theme-toggle.tsx +27 -0
  43. package/src/components/tool-call-card.tsx +126 -0
  44. package/src/components/tool-fallback.tsx +169 -0
  45. package/src/components/tool-panel-layout.tsx +36 -0
  46. package/src/components/tool-panel.tsx +233 -0
  47. package/src/components/tooltip-icon-button.tsx +28 -0
  48. package/src/components/user-menu.tsx +60 -0
  49. package/src/index.ts +170 -0
  50. package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
  51. package/src/lib/__tests__/app-store.test.ts +330 -0
  52. package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
  53. package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
  54. package/src/lib/__tests__/client-tools.test.ts +124 -0
  55. package/src/lib/__tests__/dev-store.test.ts +78 -0
  56. package/src/lib/__tests__/nav-config.test.ts +135 -0
  57. package/src/lib/__tests__/nav-dnd.test.ts +24 -0
  58. package/src/lib/__tests__/nav-store.test.ts +59 -0
  59. package/src/lib/__tests__/sidebar-store.test.ts +144 -0
  60. package/src/lib/__tests__/theme-store.test.ts +83 -0
  61. package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
  62. package/src/lib/__tests__/use-page-context.test.ts +58 -0
  63. package/src/lib/ag-ui-adapter.ts +362 -0
  64. package/src/lib/app-store.ts +294 -0
  65. package/src/lib/attachment-adapter.ts +92 -0
  66. package/src/lib/auth-provider.tsx +144 -0
  67. package/src/lib/chat-bubble-store.ts +46 -0
  68. package/src/lib/client-tools.ts +293 -0
  69. package/src/lib/dev-store.ts +69 -0
  70. package/src/lib/nav-config.ts +292 -0
  71. package/src/lib/nav-dnd.ts +14 -0
  72. package/src/lib/nav-store.ts +76 -0
  73. package/src/lib/sidebar-store.ts +113 -0
  74. package/src/lib/theme-runtime.ts +660 -0
  75. package/src/lib/theme-store.ts +67 -0
  76. package/src/lib/tool-panel-store.ts +121 -0
  77. package/src/lib/use-new-conversation.test.tsx +124 -0
  78. package/src/lib/use-new-conversation.ts +33 -0
  79. package/src/lib/use-page-tools.ts +40 -0
  80. package/src/ui/collapsible.tsx +7 -0
  81. package/src/ui/dialog.tsx +109 -0
  82. package/src/ui/dropdown-menu.tsx +71 -0
  83. package/src/ui/popover.tsx +34 -0
  84. package/src/ui/select.tsx +15 -0
  85. package/src/ui/tooltip.tsx +30 -0
  86. package/src/vite-env.d.ts +1 -0
  87. package/tsconfig.json +16 -0
  88. package/vitest.config.ts +8 -0
@@ -0,0 +1,168 @@
1
+ import { Check, Filter, X } from "lucide-react";
2
+
3
+ import { Button } from "@iloveagents/foundry-web-primitives";
4
+ import { Checkbox } from "@iloveagents/foundry-web-primitives";
5
+ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.tsx";
6
+ import { cn } from "@iloveagents/foundry-web-primitives";
7
+
8
+ export type CollectionFilterValue = string | string[] | boolean | undefined;
9
+
10
+ export interface CollectionFilterOption {
11
+ value: string;
12
+ label: string;
13
+ }
14
+
15
+ export interface CollectionFilterDefinition {
16
+ key: string;
17
+ label: string;
18
+ type: "single" | "multi" | "boolean";
19
+ options?: CollectionFilterOption[];
20
+ trueLabel?: string;
21
+ }
22
+
23
+ function activeCount(value: CollectionFilterValue, type: CollectionFilterDefinition["type"]) {
24
+ if (type === "boolean") return value ? 1 : 0;
25
+ if (Array.isArray(value)) return value.length;
26
+ return value ? 1 : 0;
27
+ }
28
+
29
+ export function CollectionFilterBar({
30
+ filters,
31
+ values,
32
+ onValueChange,
33
+ onClearAll,
34
+ className,
35
+ }: {
36
+ filters: CollectionFilterDefinition[];
37
+ values: Record<string, CollectionFilterValue>;
38
+ onValueChange: (key: string, value: CollectionFilterValue) => void;
39
+ onClearAll?: () => void;
40
+ className?: string;
41
+ }) {
42
+ const hasActiveFilters = filters.some((filter) => activeCount(values[filter.key], filter.type) > 0);
43
+
44
+ return (
45
+ <div className={cn("flex flex-wrap items-center gap-2", className)}>
46
+ {filters.map((filter) => {
47
+ const count = activeCount(values[filter.key], filter.type);
48
+ return (
49
+ <Popover key={filter.key}>
50
+ <PopoverTrigger asChild>
51
+ <Button
52
+ type="button"
53
+ variant="outline"
54
+ size="sm"
55
+ className={cn(
56
+ "rounded-xl border-border/45 bg-background/65 shadow-none hover:bg-muted/26",
57
+ count > 0 && "border-primary/18 bg-primary/7 text-foreground hover:bg-primary/10",
58
+ )}
59
+ >
60
+ <Filter className="size-4" />
61
+ <span>{filter.label}</span>
62
+ {count > 0 ? (
63
+ <span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[11px] font-semibold text-primary">
64
+ {count}
65
+ </span>
66
+ ) : null}
67
+ </Button>
68
+ </PopoverTrigger>
69
+ <PopoverContent align="start" className="w-64 rounded-2xl border-border/45 bg-popover/95 p-2 shadow-lg">
70
+ <div className="px-2 pb-2 pt-1 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
71
+ {filter.label}
72
+ </div>
73
+ {filter.type === "boolean" ? (
74
+ <button
75
+ type="button"
76
+ onClick={() => onValueChange(filter.key, values[filter.key] ? undefined : true)}
77
+ className={cn(
78
+ "flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition-colors",
79
+ values[filter.key] ? "bg-primary/8 text-foreground" : "hover:bg-accent",
80
+ )}
81
+ >
82
+ <Checkbox checked={Boolean(values[filter.key])} />
83
+ <span>{filter.trueLabel ?? `Only ${filter.label.toLowerCase()}`}</span>
84
+ </button>
85
+ ) : null}
86
+ {filter.type === "single" ? (
87
+ <div className="space-y-1">
88
+ <button
89
+ type="button"
90
+ onClick={() => onValueChange(filter.key, undefined)}
91
+ className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-accent"
92
+ >
93
+ <span>Any</span>
94
+ <Check
95
+ className={cn(
96
+ "size-4 text-primary",
97
+ values[filter.key] == null ? "opacity-100" : "opacity-0",
98
+ )}
99
+ />
100
+ </button>
101
+ {filter.options?.map((option) => (
102
+ <button
103
+ key={option.value}
104
+ type="button"
105
+ onClick={() => onValueChange(filter.key, option.value)}
106
+ className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-accent"
107
+ >
108
+ <span>{option.label}</span>
109
+ <Check
110
+ className={cn(
111
+ "size-4 text-primary",
112
+ values[filter.key] === option.value ? "opacity-100" : "opacity-0",
113
+ )}
114
+ />
115
+ </button>
116
+ ))}
117
+ </div>
118
+ ) : null}
119
+ {filter.type === "multi" ? (
120
+ <div className="space-y-1">
121
+ {filter.options?.map((option) => {
122
+ const current = Array.isArray(values[filter.key])
123
+ ? (values[filter.key] as string[])
124
+ : [];
125
+ const checked = current.includes(option.value);
126
+ return (
127
+ <button
128
+ key={option.value}
129
+ type="button"
130
+ onClick={() =>
131
+ onValueChange(
132
+ filter.key,
133
+ checked
134
+ ? current.filter((item: string) => item !== option.value)
135
+ : [...current, option.value],
136
+ )
137
+ }
138
+ className={cn(
139
+ "flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition-colors",
140
+ checked ? "bg-primary/8 text-foreground" : "hover:bg-accent",
141
+ )}
142
+ >
143
+ <Checkbox checked={checked} />
144
+ <span>{option.label}</span>
145
+ </button>
146
+ );
147
+ })}
148
+ </div>
149
+ ) : null}
150
+ </PopoverContent>
151
+ </Popover>
152
+ );
153
+ })}
154
+ {hasActiveFilters && onClearAll ? (
155
+ <Button
156
+ type="button"
157
+ variant="ghost"
158
+ size="sm"
159
+ onClick={onClearAll}
160
+ className="rounded-xl text-muted-foreground hover:bg-muted/26"
161
+ >
162
+ <X className="size-4" />
163
+ <span>Clear</span>
164
+ </Button>
165
+ ) : null}
166
+ </div>
167
+ );
168
+ }
@@ -0,0 +1,41 @@
1
+ import { Search, X } from "lucide-react";
2
+
3
+ import { Button } from "@iloveagents/foundry-web-primitives";
4
+ import { Input } from "@iloveagents/foundry-web-primitives";
5
+ import { cn } from "@iloveagents/foundry-web-primitives";
6
+
7
+ export function CollectionSearchInput({
8
+ value,
9
+ onValueChange,
10
+ placeholder = "Search...",
11
+ className,
12
+ }: {
13
+ value: string;
14
+ onValueChange: (value: string) => void;
15
+ placeholder?: string;
16
+ className?: string;
17
+ }) {
18
+ return (
19
+ <div className={cn("relative min-w-[16rem] flex-1", className)}>
20
+ <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
21
+ <Input
22
+ value={value}
23
+ onChange={(event) => onValueChange(event.target.value)}
24
+ placeholder={placeholder}
25
+ className="h-10 rounded-xl border-border/45 bg-background/72 pl-9 pr-10 shadow-none hover:bg-background/82 focus:border-primary/20 focus:ring-primary/10"
26
+ />
27
+ {value ? (
28
+ <Button
29
+ type="button"
30
+ variant="ghost"
31
+ size="icon"
32
+ onClick={() => onValueChange("")}
33
+ className="absolute right-1 top-1/2 size-8 -translate-y-1/2 rounded-lg text-muted-foreground"
34
+ >
35
+ <X className="size-4" />
36
+ <span className="sr-only">Clear search</span>
37
+ </Button>
38
+ ) : null}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,55 @@
1
+ import { cn } from "@iloveagents/foundry-web-primitives";
2
+
3
+ import type { CollectionViewMode } from "./collection-view-toggle.tsx";
4
+
5
+ export function CollectionSkeleton({
6
+ view,
7
+ rows = 8,
8
+ className,
9
+ }: {
10
+ view: CollectionViewMode;
11
+ rows?: number;
12
+ className?: string;
13
+ }) {
14
+ if (view === "table") {
15
+ return (
16
+ <div className={cn("overflow-hidden rounded-[1.5rem] bg-background/65 ring-1 ring-border/35", className)}>
17
+ <div className="grid grid-cols-4 gap-4 border-b border-border/40 bg-muted/28 px-5 py-4">
18
+ {Array.from({ length: 4 }).map((_, index) => (
19
+ <div key={index} className="h-3.5 animate-pulse rounded bg-muted" />
20
+ ))}
21
+ </div>
22
+ <div className="divide-y divide-border/35">
23
+ {Array.from({ length: rows }).map((_, index) => (
24
+ <div key={index} className="grid grid-cols-4 gap-4 px-5 py-4">
25
+ {Array.from({ length: 4 }).map((_, cellIndex) => (
26
+ <div key={cellIndex} className="h-4 animate-pulse rounded bg-muted/80" />
27
+ ))}
28
+ </div>
29
+ ))}
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <div
37
+ className={cn(
38
+ view === "tiles"
39
+ ? "grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(min(100%,16rem),1fr))]"
40
+ : "grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(min(100%,18rem),1fr))]",
41
+ className,
42
+ )}
43
+ >
44
+ {Array.from({ length: rows }).map((_, index) => (
45
+ <div
46
+ key={index}
47
+ className={cn(
48
+ "animate-pulse rounded-[1.5rem] bg-card/80 ring-1 ring-border/35",
49
+ view === "tiles" ? "h-28" : "h-40",
50
+ )}
51
+ />
52
+ ))}
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,62 @@
1
+ import { ArrowUpDown, Check } from "lucide-react";
2
+
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuTrigger,
8
+ } from "../ui/dropdown-menu.tsx";
9
+ import { Button } from "@iloveagents/foundry-web-primitives";
10
+ import { cn } from "@iloveagents/foundry-web-primitives";
11
+
12
+ export interface CollectionSortOption {
13
+ value: string;
14
+ label: string;
15
+ }
16
+
17
+ export function CollectionSortMenu({
18
+ options,
19
+ value,
20
+ onValueChange,
21
+ className,
22
+ }: {
23
+ options: CollectionSortOption[];
24
+ value: string;
25
+ onValueChange: (value: string) => void;
26
+ className?: string;
27
+ }) {
28
+ const active = options.find((option) => option.value === value) ?? options[0];
29
+
30
+ return (
31
+ <DropdownMenu>
32
+ <DropdownMenuTrigger asChild>
33
+ <Button
34
+ type="button"
35
+ variant="outline"
36
+ size="sm"
37
+ className={cn(
38
+ "rounded-xl border-border/45 bg-background/65 shadow-none hover:bg-muted/26",
39
+ className,
40
+ )}
41
+ >
42
+ <ArrowUpDown className="size-4" />
43
+ <span>{active?.label ?? "Sort"}</span>
44
+ </Button>
45
+ </DropdownMenuTrigger>
46
+ <DropdownMenuContent align="end" className="w-56 rounded-xl border-border/45 bg-popover/95 shadow-lg">
47
+ {options.map((option) => (
48
+ <DropdownMenuItem
49
+ key={option.value}
50
+ onClick={() => onValueChange(option.value)}
51
+ className="flex items-center justify-between gap-3"
52
+ >
53
+ <span>{option.label}</span>
54
+ <Check
55
+ className={cn("size-4 text-primary", value === option.value ? "opacity-100" : "opacity-0")}
56
+ />
57
+ </DropdownMenuItem>
58
+ ))}
59
+ </DropdownMenuContent>
60
+ </DropdownMenu>
61
+ );
62
+ }
@@ -0,0 +1,46 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+
3
+ import { cn } from "@iloveagents/foundry-web-primitives";
4
+
5
+ export function CollectionSurface({
6
+ toolbar,
7
+ bordered = false,
8
+ backgrounded = true,
9
+ className,
10
+ toolbarClassName,
11
+ contentClassName,
12
+ children,
13
+ ...props
14
+ }: ComponentPropsWithoutRef<"section"> & {
15
+ toolbar?: ReactNode;
16
+ bordered?: boolean;
17
+ backgrounded?: boolean;
18
+ toolbarClassName?: string;
19
+ contentClassName?: string;
20
+ }) {
21
+ return (
22
+ <section
23
+ className={cn(
24
+ "w-full min-w-0 overflow-hidden rounded-[1.75rem]",
25
+ backgrounded && "bg-card/60 shadow-[0_12px_36px_-24px_rgb(15_23_42/0.18)] backdrop-blur-sm",
26
+ bordered && "ring-1 ring-border/45",
27
+ className,
28
+ )}
29
+ {...props}
30
+ >
31
+ {toolbar ? (
32
+ <div
33
+ className={cn(
34
+ "sticky top-0 z-10 px-4 py-4 sm:px-5",
35
+ backgrounded && "bg-muted/18 backdrop-blur",
36
+ bordered && "border-b border-border/45",
37
+ toolbarClassName,
38
+ )}
39
+ >
40
+ {toolbar}
41
+ </div>
42
+ ) : null}
43
+ <div className={cn("p-4 sm:p-5", contentClassName)}>{children}</div>
44
+ </section>
45
+ );
46
+ }
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { cn } from "@iloveagents/foundry-web-primitives";
4
+
5
+ export function CollectionToolbar({
6
+ search,
7
+ filters,
8
+ sort,
9
+ viewToggle,
10
+ actions,
11
+ className,
12
+ }: {
13
+ search?: ReactNode;
14
+ filters?: ReactNode;
15
+ sort?: ReactNode;
16
+ viewToggle?: ReactNode;
17
+ actions?: ReactNode;
18
+ className?: string;
19
+ }) {
20
+ return (
21
+ <div className={cn("flex flex-col gap-2.5", className)}>
22
+ <div className="flex flex-col gap-2.5 lg:flex-row lg:items-center">
23
+ <div className="flex min-w-0 flex-1 items-center gap-3">{search}</div>
24
+ <div className="flex flex-wrap items-center gap-2">
25
+ {sort}
26
+ {viewToggle}
27
+ {actions}
28
+ </div>
29
+ </div>
30
+ {filters ? <div className="flex flex-wrap items-center gap-2">{filters}</div> : null}
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,61 @@
1
+ import { LayoutGrid, Rows3, TableProperties } from "lucide-react";
2
+
3
+ import { Button } from "@iloveagents/foundry-web-primitives";
4
+ import { cn } from "@iloveagents/foundry-web-primitives";
5
+
6
+ export type CollectionViewMode = "table" | "grid" | "tiles";
7
+
8
+ const VIEW_OPTIONS: Array<{
9
+ value: CollectionViewMode;
10
+ label: string;
11
+ icon: typeof TableProperties;
12
+ }> = [
13
+ { value: "table", label: "Table", icon: TableProperties },
14
+ { value: "grid", label: "Grid", icon: LayoutGrid },
15
+ { value: "tiles", label: "Tiles", icon: Rows3 },
16
+ ];
17
+
18
+ export function CollectionViewToggle({
19
+ value,
20
+ onValueChange,
21
+ views = ["table", "grid"],
22
+ className,
23
+ }: {
24
+ value: CollectionViewMode;
25
+ onValueChange: (value: CollectionViewMode) => void;
26
+ views?: CollectionViewMode[];
27
+ className?: string;
28
+ }) {
29
+ return (
30
+ <div
31
+ className={cn(
32
+ "inline-flex items-center rounded-xl bg-muted/26 p-1 ring-1 ring-border/40",
33
+ className,
34
+ )}
35
+ >
36
+ {VIEW_OPTIONS.filter((option) => views.includes(option.value)).map((option) => {
37
+ const Icon = option.icon;
38
+ const active = value === option.value;
39
+ return (
40
+ <Button
41
+ key={option.value}
42
+ type="button"
43
+ aria-label={option.label}
44
+ variant={active ? "secondary" : "ghost"}
45
+ size="sm"
46
+ onClick={() => onValueChange(option.value)}
47
+ className={cn(
48
+ "rounded-lg px-2.5 text-muted-foreground hover:bg-background/70 hover:text-foreground",
49
+ active &&
50
+ "bg-background text-foreground ring-1 ring-primary/35 shadow-[0_1px_2px_rgb(15_23_42/0.08)] hover:bg-background",
51
+ )}
52
+ >
53
+ <Icon className="size-4" />
54
+ <span className="hidden sm:inline">{option.label}</span>
55
+ <span className="sr-only sm:hidden">{option.label}</span>
56
+ </Button>
57
+ );
58
+ })}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,49 @@
1
+ import { Button } from "@iloveagents/foundry-web-primitives";
2
+ import { FormDialog } from "./form-dialog.tsx";
3
+
4
+ export function ConfirmDialog({
5
+ open,
6
+ onOpenChange,
7
+ title,
8
+ description,
9
+ confirmLabel = "Confirm",
10
+ cancelLabel = "Cancel",
11
+ destructive = false,
12
+ onConfirm,
13
+ confirming = false,
14
+ }: {
15
+ open: boolean;
16
+ onOpenChange: (open: boolean) => void;
17
+ title: string;
18
+ description?: string;
19
+ confirmLabel?: string;
20
+ cancelLabel?: string;
21
+ destructive?: boolean;
22
+ onConfirm: () => void;
23
+ confirming?: boolean;
24
+ }) {
25
+ return (
26
+ <FormDialog
27
+ open={open}
28
+ onOpenChange={onOpenChange}
29
+ title={title}
30
+ description={description}
31
+ size="sm"
32
+ footer={
33
+ <>
34
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
35
+ {cancelLabel}
36
+ </Button>
37
+ <Button
38
+ type="button"
39
+ variant={destructive ? "destructive" : "default"}
40
+ onClick={onConfirm}
41
+ disabled={confirming}
42
+ >
43
+ {confirmLabel}
44
+ </Button>
45
+ </>
46
+ }
47
+ />
48
+ );
49
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Confirmation Card — informational UI for the ui_confirm tool.
3
+ *
4
+ * Renders a card with action description and optional details.
5
+ * The user responds by typing their confirmation in the chat.
6
+ */
7
+
8
+ import { ShieldCheck } from "lucide-react";
9
+ import { ToolCallCard, type ToolCallStatus } from "./tool-call-card.tsx";
10
+
11
+ interface ConfirmationCardProps {
12
+ action: string;
13
+ details?: string;
14
+ status: ToolCallStatus;
15
+ }
16
+
17
+ export function ConfirmationCard({ action, details, status }: ConfirmationCardProps) {
18
+ return (
19
+ <ToolCallCard
20
+ icon={<ShieldCheck className="size-3.5" />}
21
+ title="Confirmation Required"
22
+ description={action}
23
+ status={status}
24
+ className="w-full"
25
+ >
26
+ {details && <p className="text-xs text-muted-foreground mt-1">{details}</p>}
27
+ <p className="text-xs text-muted-foreground mt-2 italic">
28
+ Reply to confirm or reject this action.
29
+ </p>
30
+ </ToolCallCard>
31
+ );
32
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Context badge components for the composer and sent messages.
3
+ *
4
+ * Renders user-selected context items as violet-tinted badges,
5
+ * visually distinct from gray file attachment badges.
6
+ *
7
+ * - ComposerContextBadges: editable badges in the composer (with remove button)
8
+ * - SentContextBadges: read-only badges on sent user messages
9
+ */
10
+
11
+ import { type FC, createElement } from "react";
12
+ import { Pin, TextSelect, X, FileText, Link2, StickyNote, type LucideIcon } from "lucide-react";
13
+ import { cn } from "@iloveagents/foundry-web-primitives";
14
+ import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip.tsx";
15
+ import { useAppStore, type ContextItem, type ContextItemType } from "../lib/app-store.ts";
16
+
17
+ const CONTEXT_ICONS: Record<ContextItemType, LucideIcon> = {
18
+ selection: TextSelect,
19
+ page: FileText,
20
+ ref: Link2,
21
+ note: StickyNote,
22
+ };
23
+
24
+ function ContextIcon({ type, className }: { type: ContextItemType; className?: string }) {
25
+ return createElement(CONTEXT_ICONS[type] ?? TextSelect, { className });
26
+ }
27
+
28
+ /** Get tooltip text for a context item (source page or path). */
29
+ function getBadgeTooltip(item: ContextItem): string {
30
+ const source =
31
+ item.type === "page" && item.payload.kind === "page" ? item.payload.path : item.sourcePage;
32
+ return `${item.label}\n${source}`;
33
+ }
34
+
35
+ /** Editable badge in the composer with a remove button. */
36
+ const ComposerBadge: FC<{ item: ContextItem }> = ({ item }) => {
37
+ const removeItem = useAppStore((s) => s.removeContextItem);
38
+ const promoteItem = useAppStore((s) => s.promoteContextItem);
39
+ const isEphemeral = item.persistence === "ephemeral";
40
+
41
+ return (
42
+ <Tooltip>
43
+ <TooltipTrigger asChild>
44
+ <div
45
+ className={cn(
46
+ "inline-flex items-center gap-1.5 max-w-70",
47
+ "rounded-lg bg-primary/5",
48
+ "px-2 py-1",
49
+ isEphemeral ? "border border-dashed border-primary/40" : "border border-primary/20",
50
+ )}
51
+ >
52
+ <ContextIcon type={item.type} className="size-3 text-primary shrink-0" />
53
+ <span className="text-xs text-foreground truncate">{item.label}</span>
54
+ {isEphemeral && (
55
+ <button
56
+ type="button"
57
+ onClick={() => promoteItem(item.id)}
58
+ className="p-0.5 rounded hover:bg-primary/10 shrink-0"
59
+ aria-label="Pin context"
60
+ >
61
+ <Pin className="size-3 text-muted-foreground" />
62
+ </button>
63
+ )}
64
+ <button
65
+ type="button"
66
+ onClick={() => removeItem(item.id)}
67
+ className="p-0.5 rounded hover:bg-primary/10 shrink-0"
68
+ aria-label="Remove context"
69
+ >
70
+ <X className="size-3" />
71
+ </button>
72
+ </div>
73
+ </TooltipTrigger>
74
+ <TooltipContent side="top" className="whitespace-pre-line">
75
+ {getBadgeTooltip(item)}
76
+ </TooltipContent>
77
+ </Tooltip>
78
+ );
79
+ };
80
+
81
+ /** Read-only badge on a sent user message (no remove button, compact). */
82
+ const SentBadge: FC<{ item: ContextItem }> = ({ item }) => (
83
+ <div
84
+ className={cn(
85
+ "inline-flex items-center gap-1 max-w-60",
86
+ "rounded-md border border-primary/20 bg-primary/5",
87
+ "px-2 py-1",
88
+ )}
89
+ >
90
+ <ContextIcon type={item.type} className="size-3 text-primary shrink-0" />
91
+ <span className="text-[11px] leading-tight text-foreground truncate">{item.label}</span>
92
+ </div>
93
+ );
94
+
95
+ /** Renders ephemeral context badges in the composer (persistent items shown in ContextPins). */
96
+ export const ComposerContextBadges: FC = () => {
97
+ const items = useAppStore((s) => s.contextItems);
98
+ const ephemeral = items.filter((i) => i.persistence === "ephemeral");
99
+
100
+ if (ephemeral.length === 0) return null;
101
+
102
+ return (
103
+ <div className="flex flex-wrap gap-1.5">
104
+ {ephemeral.map((item) => (
105
+ <ComposerBadge key={item.id} item={item} />
106
+ ))}
107
+ </div>
108
+ );
109
+ };
110
+
111
+ /** Renders sent context badges on a user message (read-only). */
112
+ export const SentContextBadges: FC<{ messageId: string }> = ({ messageId }) => {
113
+ const items = useAppStore((s) => s.sentContext[messageId]);
114
+
115
+ if (!items || items.length === 0) return null;
116
+
117
+ return (
118
+ <div className="flex flex-wrap gap-1 mb-1">
119
+ {items.map((item) => (
120
+ <SentBadge key={item.id} item={item} />
121
+ ))}
122
+ </div>
123
+ );
124
+ };