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
12 changes: 11 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind + Shadcn. It renders JSON metadata from the @objectstack/spec protocol into pixel-perfect, accessible, and interactive enterprise interfaces.

**Where We Are:** Foundation is **solid and shipping** — 35 packages, 91+ components, 5,110+ tests, 78 Storybook stories, 42/42 builds passing, ~85% protocol alignment. SpecBridge, Expression Engine, Action Engine, data binding, all view plugins (Grid/Kanban/Calendar/Gantt/Timeline/Map/Gallery), Record components, Report engine, Dashboard BI features, mobile UX, i18n (11 locales), WCAG AA accessibility, Designer Phase 1 (ViewDesigner drag-to-reorder ✅), Console through Phase 20 (L3), **AppShell Navigation Renderer** (P0.1), and **Flow Designer** (P2.4) — all ✅ complete.
**Where We Are:** Foundation is **solid and shipping** — 35 packages, 99+ components, 5,177+ tests, 78 Storybook stories, 42/42 builds passing, ~85% protocol alignment. SpecBridge, Expression Engine, Action Engine, data binding, all view plugins (Grid/Kanban/Calendar/Gantt/Timeline/Map/Gallery), Record components, Report engine, Dashboard BI features, mobile UX, i18n (11 locales), WCAG AA accessibility, Designer Phase 1 (ViewDesigner drag-to-reorder ✅), Console through Phase 20 (L3), **AppShell Navigation Renderer** (P0.1), **Flow Designer** (P2.4), and **Feed/Chatter UI** (P1.5) — all ✅ complete.

**What Remains:** The gap to **Airtable-level UX** is primarily in:
1. ~~**AppShell** — No dynamic navigation renderer from spec JSON (last P0 blocker)~~ ✅ Complete
Expand Down Expand Up @@ -130,6 +130,16 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
- [x] Comment search across all records
- [x] Comment pinning/starring
- [x] Activity feed filtering (comments only / field changes only)
- [x] Airtable-style Feed/Chatter UI components (P0/P1/P2):
- [x] `FeedItem`/`FieldChangeEntry`/`Mention`/`Reaction`/`RecordSubscription` types
- [x] `RecordActivityTimeline` — unified timeline renderer (filter, pagination, actor display)
- [x] `RecordChatterPanel` — sidebar/inline/drawer panel (collapsible)
- [x] `CommentInput` — comment input with Ctrl+Enter submit
- [x] `FieldChangeItem` — field change history (old→new display values)
- [x] `MentionAutocomplete` — @mention autocomplete dropdown
- [x] `SubscriptionToggle` — bell notification toggle
- [x] `ReactionPicker` — emoji reaction selector
- [x] `ThreadedReplies` — collapsible comment reply threading

### P1.6 Console — Automation

Expand Down
81 changes: 81 additions & 0 deletions packages/plugin-detail/src/CommentInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import * as React from 'react';
import { cn, Button } from '@object-ui/components';
import { Send } from 'lucide-react';

export interface CommentInputProps {
/** Called when a comment is submitted */
onSubmit: (text: string) => void | Promise<void>;
/** Placeholder text */
placeholder?: string;
/** Whether the input is disabled */
disabled?: boolean;
className?: string;
}

/**
* CommentInput — Simple comment input component.
* Renders a "Leave a comment" textarea with submit button.
* Supports Ctrl+Enter to submit.
*/
export const CommentInput: React.FC<CommentInputProps> = ({
onSubmit,
placeholder = 'Leave a comment…',
disabled = false,
className,
}) => {
const [text, setText] = React.useState('');
const [isSubmitting, setIsSubmitting] = React.useState(false);

const handleSubmit = React.useCallback(async () => {
const value = text.trim();
if (!value) return;
setIsSubmitting(true);
try {
await onSubmit(value);
setText('');
} finally {
setIsSubmitting(false);
}
}, [text, onSubmit]);

const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);

return (
<div className={cn('flex gap-2', className)}>
<textarea
className="flex-1 min-h-[60px] rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
placeholder={placeholder}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled || isSubmitting}
/>
<Button
size="icon"
variant="default"
onClick={handleSubmit}
disabled={!text.trim() || isSubmitting || disabled}
className="shrink-0 self-end"
aria-label="Submit comment"
>
<Send className="h-4 w-4" />
</Button>
</div>
);
};
46 changes: 46 additions & 0 deletions packages/plugin-detail/src/FieldChangeItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import * as React from 'react';
import { cn } from '@object-ui/components';
import { ArrowRight } from 'lucide-react';
import type { FieldChangeEntry } from '@object-ui/types';

export interface FieldChangeItemProps {
/** The field change entry to render */
change: FieldChangeEntry;
className?: string;
}

/**
* FieldChangeItem — Renders a single field change entry.
* Shows: field label → old value → new value with human-readable display values.
* Aligned with @objectstack/spec FieldChangeEntrySchema.
*/
export const FieldChangeItem: React.FC<FieldChangeItemProps> = ({
change,
className,
}) => {
const label =
change.fieldLabel ??
change.field.charAt(0).toUpperCase() + change.field.slice(1).replace(/_/g, ' ');

const oldDisplay =
change.oldDisplayValue ?? (change.oldValue != null ? String(change.oldValue) : '(empty)');
const newDisplay =
change.newDisplayValue ?? (change.newValue != null ? String(change.newValue) : '(empty)');

return (
<div className={cn('flex items-center gap-1.5 text-sm flex-wrap', className)}>
<span className="font-medium text-foreground">{label}</span>
<span className="text-muted-foreground line-through">{oldDisplay}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="text-foreground">{newDisplay}</span>
</div>
);
};
123 changes: 123 additions & 0 deletions packages/plugin-detail/src/MentionAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import * as React from 'react';
import { cn } from '@object-ui/components';
import type { Mention } from '@object-ui/types';

export interface MentionSuggestionItem {
/** Entity ID */
id: string;
/** Display name */
name: string;
/** Avatar URL */
avatarUrl?: string;
/** Entity type */
type: 'user' | 'team' | 'group';
}

export interface MentionAutocompleteProps {
/** Search query (text after @) */
query: string;
/** Available suggestions */
suggestions: MentionSuggestionItem[];
/** Called when a suggestion is selected */
onSelect: (item: MentionSuggestionItem) => void;
/** Whether the dropdown is visible */
visible?: boolean;
/** Active/highlighted index */
activeIndex?: number;
className?: string;
}

/**
* MentionAutocomplete — Dropdown for @mention autocomplete.
* Filters suggestions by query and renders a selectable list.
* Produces MentionSchema data on selection.
*/
export const MentionAutocomplete: React.FC<MentionAutocompleteProps> = ({
query,
suggestions,
onSelect,
visible = true,
activeIndex = 0,
className,
}) => {
const filtered = React.useMemo(() => {
if (!query) return suggestions;
const q = query.toLowerCase();
return suggestions.filter(
(s) => s.name.toLowerCase().includes(q) || s.id.toLowerCase().includes(q),
);
}, [query, suggestions]);

if (!visible || filtered.length === 0) return null;

return (
<div
className={cn(
'bg-popover border rounded-md shadow-md z-50 max-h-48 overflow-y-auto w-56',
className,
)}
role="listbox"
aria-label="Mention suggestions"
>
{filtered.map((item, index) => (
<button
key={item.id}
type="button"
role="option"
aria-selected={index === activeIndex}
className={cn(
'w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 hover:bg-accent transition-colors',
index === activeIndex && 'bg-accent',
)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(item);
}}
>
{item.avatarUrl ? (
<img
src={item.avatarUrl}
alt={item.name}
className="h-5 w-5 rounded-full object-cover"
/>
) : (
<div className="h-5 w-5 rounded-full bg-muted flex items-center justify-center text-[10px] font-medium">
{item.name.charAt(0).toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<span className="truncate">{item.name}</span>
{item.type !== 'user' && (
<span className="ml-1 text-xs text-muted-foreground capitalize">({item.type})</span>
)}
</div>
</button>
))}
</div>
);
};

/**
* Helper to create a Mention object from a suggestion item.
*/
export function createMentionFromSuggestion(
item: MentionSuggestionItem,
offset: number,
length: number,
): Mention {
return {
type: item.type,
id: item.id,
name: item.name,
offset,
length,
};
}
106 changes: 106 additions & 0 deletions packages/plugin-detail/src/ReactionPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import * as React from 'react';
import { cn, Button } from '@object-ui/components';
import { SmilePlus } from 'lucide-react';
import type { Reaction } from '@object-ui/types';

const DEFAULT_EMOJI_OPTIONS = ['👍', '❤️', '🎉', '😂', '😮', '😢'];

export interface ReactionPickerProps {
/** Existing reactions on the feed item */
reactions: Reaction[];
/** Called when user adds or removes a reaction */
onToggleReaction?: (emoji: string) => void | Promise<void>;
/** Available emoji options */
emojiOptions?: string[];
className?: string;
}

/**
* ReactionPicker — Emoji reaction selector and display.
* Shows existing reactions with counts, and a picker to add/remove.
* Aligned with @objectstack/spec ReactionSchema.
*/
export const ReactionPicker: React.FC<ReactionPickerProps> = ({
reactions,
onToggleReaction,
emojiOptions = DEFAULT_EMOJI_OPTIONS,
className,
}) => {
const [showPicker, setShowPicker] = React.useState(false);

const handleReaction = React.useCallback(
(emoji: string) => {
onToggleReaction?.(emoji);
setShowPicker(false);
},
[onToggleReaction],
);

return (
<div className={cn('flex items-center gap-1 flex-wrap', className)}>
{/* Existing reactions */}
{reactions.map((reaction) => (
<button
key={reaction.emoji}
type="button"
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs border transition-colors',
reaction.reacted
? 'bg-primary/10 border-primary/30 text-primary'
: 'bg-muted border-border text-muted-foreground hover:bg-muted/80',
)}
onClick={() => handleReaction(reaction.emoji)}
disabled={!onToggleReaction}
aria-label={`${reaction.emoji} ${reaction.count} reaction${reaction.count !== 1 ? 's' : ''}`}
>
<span>{reaction.emoji}</span>
<span>{reaction.count}</span>
</button>
))}

{/* Add reaction button */}
{onToggleReaction && (
<div className="relative">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setShowPicker(!showPicker)}
aria-label="Add reaction"
>
<SmilePlus className="h-3.5 w-3.5" />
</Button>

{showPicker && (
<div
className="absolute bottom-full mb-1 left-0 bg-popover border rounded-md shadow-md z-50 p-1.5 flex gap-1"
role="listbox"
aria-label="Emoji picker"
>
{emojiOptions.map((emoji) => (
<button
key={emoji}
type="button"
className="hover:bg-accent rounded p-1 text-base transition-colors"
onClick={() => handleReaction(emoji)}
role="option"
aria-selected={reactions.some(r => r.emoji === emoji && r.reacted)}
>
{emoji}
</button>
))}
</div>
)}
Comment on lines +82 to +101
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

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

The emoji picker popup does not close when clicking outside of it. Consider adding a click-away handler (e.g., using useEffect with document click listener or a library like react-use's useClickAway) to improve UX by automatically closing the picker when the user clicks elsewhere.

Copilot uses AI. Check for mistakes.
</div>
)}
</div>
);
};
Loading