mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(web): integrate applied plugin snapshot for enhanced user experience
- Added support for displaying an active plugin as a context chip in user messages when a project is created with a pinned plugin. This replaces the in-composer plugin rail to avoid re-prompting users for plugin selection. - Introduced `applied_plugin_snapshot_id` in the database schema and updated relevant components (ChatComposer, ChatPane, ProjectView) to handle the new functionality. - Implemented fetching of the applied plugin snapshot in ProjectView to ensure the active plugin is rendered correctly. - Enhanced CSS for the plugin chip to improve visual presentation. This change streamlines the user experience by providing context on previously selected plugins directly within the chat interface.
This commit is contained in:
parent
175629193f
commit
b3dc3c3e0c
8 changed files with 251 additions and 2 deletions
|
|
@ -374,6 +374,7 @@ const PROJECT_COLS = `id, name, skill_id AS skillId,
|
|||
design_system_id AS designSystemId,
|
||||
pending_prompt AS pendingPrompt,
|
||||
metadata_json AS metadataJson,
|
||||
applied_plugin_snapshot_id AS appliedPluginSnapshotId,
|
||||
created_at AS createdAt,
|
||||
updated_at AS updatedAt`;
|
||||
|
||||
|
|
@ -523,6 +524,7 @@ function normalizeProject(row: DbRow) {
|
|||
designSystemId: row.designSystemId,
|
||||
pendingPrompt: row.pendingPrompt ?? undefined,
|
||||
metadata,
|
||||
appliedPluginSnapshotId: row.appliedPluginSnapshotId ?? undefined,
|
||||
createdAt: Number(row.createdAt),
|
||||
updatedAt: Number(row.updatedAt),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ interface Props {
|
|||
researchAvailable?: boolean;
|
||||
projectMetadata?: ProjectMetadata;
|
||||
onProjectMetadataChange?: (metadata: ProjectMetadata) => void;
|
||||
// Set when the project was created with a plugin already pinned
|
||||
// (PluginLoopHome on Home). We suppress the in-composer plugin rail
|
||||
// so the user is not re-prompted to pick a plugin they already chose;
|
||||
// the active plugin shows up as a context chip on each user message
|
||||
// (see UserMessage in ChatPane).
|
||||
hidePluginsRail?: boolean;
|
||||
}
|
||||
|
||||
// Imperative handle so ancestors (e.g. example chips in ChatPane) can
|
||||
|
|
@ -111,6 +117,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
researchAvailable = false,
|
||||
projectMetadata,
|
||||
onProjectMetadataChange,
|
||||
hidePluginsRail = false,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
|
|
@ -696,8 +703,14 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
applying a plugin hydrates the draft with the rendered
|
||||
brief, leaving the existing send / @-mention / staged
|
||||
attachment flows untouched.
|
||||
|
||||
Hidden when the project was created with a plugin pinned
|
||||
(PluginLoopHome on Home): re-rendering the rail there would
|
||||
re-prompt the user to pick a plugin they already chose. The
|
||||
active plugin appears as a context chip on user messages
|
||||
(UserMessage in ChatPane) instead.
|
||||
*/}
|
||||
{projectId ? (
|
||||
{projectId && !hidePluginsRail ? (
|
||||
<PluginsSection
|
||||
projectId={projectId}
|
||||
variant="strip"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useT } from '../i18n';
|
|||
import type { Dict } from '../i18n/types';
|
||||
import { projectRawUrl } from '../providers/registry';
|
||||
import type { TodoItem } from '../runtime/todos';
|
||||
import type { AppliedPluginSnapshot } from '@open-design/contracts';
|
||||
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile, ProjectMetadata } from '../types';
|
||||
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
|
||||
import { commentsToAttachments, simplePositionLabel } from '../comments';
|
||||
|
|
@ -98,6 +99,12 @@ interface Props {
|
|||
projectMetadata?: ProjectMetadata;
|
||||
onProjectMetadataChange?: (metadata: ProjectMetadata) => void;
|
||||
researchAvailable?: boolean;
|
||||
// Immutable snapshot of the plugin pinned to this project. When set
|
||||
// we suppress the in-composer plugin rail (the user already picked a
|
||||
// plugin on Home) and render the active plugin as a context chip on
|
||||
// each user message — that satisfies §8 "show context inside the run
|
||||
// message" without forcing a separate side widget.
|
||||
activePluginSnapshot?: AppliedPluginSnapshot | null;
|
||||
}
|
||||
|
||||
type Tab = 'chat' | 'comments';
|
||||
|
|
@ -136,6 +143,7 @@ export function ChatPane({
|
|||
projectMetadata,
|
||||
onProjectMetadataChange,
|
||||
researchAvailable,
|
||||
activePluginSnapshot,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const logRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -149,6 +157,10 @@ export function ChatPane({
|
|||
const hasActiveRunMessage = messages.some(
|
||||
(m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus),
|
||||
);
|
||||
// Only the first user message gets the active-plugin chip — the
|
||||
// plugin is project-scoped so re-stamping it on every reply would be
|
||||
// noise. Subsequent messages still run under the same snapshot.
|
||||
const firstUserMessageId = messages.find((m) => m.role === 'user')?.id;
|
||||
// Map each assistant message id to the user message that follows it
|
||||
// (if any) so QuestionFormView can render its locked "answered" state
|
||||
// with the user's picks.
|
||||
|
|
@ -463,6 +475,11 @@ export function ChatPane({
|
|||
projectFileNames={projectFileNames}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
t={t}
|
||||
activePluginSnapshot={
|
||||
m.id === firstUserMessageId
|
||||
? activePluginSnapshot ?? null
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
|
|
@ -518,6 +535,7 @@ export function ChatPane({
|
|||
researchAvailable={researchAvailable}
|
||||
projectMetadata={projectMetadata}
|
||||
onProjectMetadataChange={onProjectMetadataChange}
|
||||
hidePluginsRail={!!activePluginSnapshot}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
|
@ -739,12 +757,14 @@ function UserMessage({
|
|||
projectFileNames,
|
||||
onRequestOpenFile,
|
||||
t,
|
||||
activePluginSnapshot,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
projectId: string | null;
|
||||
projectFileNames?: Set<string>;
|
||||
onRequestOpenFile?: (name: string) => void;
|
||||
t: TranslateFn;
|
||||
activePluginSnapshot?: AppliedPluginSnapshot | null;
|
||||
}) {
|
||||
const attachments = message.attachments ?? [];
|
||||
const commentAttachments = message.commentAttachments ?? [];
|
||||
|
|
@ -754,6 +774,9 @@ function UserMessage({
|
|||
<span>{t('chat.you')}</span>
|
||||
<MessageTimestamp message={message} t={t} />
|
||||
</div>
|
||||
{activePluginSnapshot ? (
|
||||
<ActivePluginChip snapshot={activePluginSnapshot} t={t} />
|
||||
) : null}
|
||||
{attachments.length > 0 ? (
|
||||
<div className="user-attachments">
|
||||
{attachments.map((a) => {
|
||||
|
|
@ -801,6 +824,36 @@ function UserMessage({
|
|||
);
|
||||
}
|
||||
|
||||
// Context chip rendered above a user message when the project pinned a
|
||||
// plugin at create time (PluginLoopHome on Home). Replaces the noisy
|
||||
// in-composer plugin rail so the user is not re-prompted to pick
|
||||
// something they already chose; instead the active plugin lives inside
|
||||
// the run message it kicked off.
|
||||
function ActivePluginChip({
|
||||
snapshot,
|
||||
t: _t,
|
||||
}: {
|
||||
snapshot: AppliedPluginSnapshot;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const title = snapshot.pluginTitle ?? snapshot.pluginId;
|
||||
const version = snapshot.pluginVersion;
|
||||
const taskKind = snapshot.taskKind;
|
||||
return (
|
||||
<div className="msg-plugin-chip" data-testid="msg-plugin-chip">
|
||||
<span className="msg-plugin-chip__dot" aria-hidden />
|
||||
<span className="msg-plugin-chip__label">
|
||||
<span className="msg-plugin-chip__kind">Plugin</span>
|
||||
<span className="msg-plugin-chip__title">{title}</span>
|
||||
<span className="msg-plugin-chip__version">@{version}</span>
|
||||
</span>
|
||||
{taskKind ? (
|
||||
<span className="msg-plugin-chip__task">{taskKind}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DaySeparator({ ts }: { ts: number | undefined }) {
|
||||
if (!ts) return null;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { isLiveArtifactTabId, liveArtifactTabId } from '../types';
|
|||
import {
|
||||
createConversation,
|
||||
deleteConversation as deleteConversationApi,
|
||||
fetchAppliedPluginSnapshot,
|
||||
getTemplate,
|
||||
listConversations,
|
||||
listMessages,
|
||||
|
|
@ -55,6 +56,7 @@ import {
|
|||
saveMessage,
|
||||
saveTabs,
|
||||
} from '../state/projects';
|
||||
import type { AppliedPluginSnapshot } from '@open-design/contracts';
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentInfo,
|
||||
|
|
@ -795,7 +797,25 @@ export function ProjectView({
|
|||
if (!isActiveRunStatus(message.runStatus)) continue;
|
||||
const fallbackRun = !message.runId ? activeByMessage.get(message.id) : null;
|
||||
const runId = message.runId ?? fallbackRun?.id;
|
||||
if (!runId) continue;
|
||||
// Self-heal phantom 'running' rows: when the message has no runId
|
||||
// and the daemon has no active run mapped to it, the original send
|
||||
// POST was lost (daemon restart mid-flight, the user navigated
|
||||
// away before /api/runs returned, or a network blip). Leaving the
|
||||
// message as 'running' is what produces the "Waiting for first
|
||||
// output — Working 24m+" UI the user reported. Mark it failed so
|
||||
// the composer is interactive again and the user can re-send.
|
||||
if (!runId) {
|
||||
updateMessageById(
|
||||
message.id,
|
||||
(prev) => ({
|
||||
...prev,
|
||||
runStatus: 'failed',
|
||||
endedAt: prev.endedAt ?? Date.now(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (reattachControllersRef.current.has(runId)) continue;
|
||||
if (completedReattachRunsRef.current.has(runId)) continue;
|
||||
|
||||
|
|
@ -1762,6 +1782,30 @@ export function ProjectView({
|
|||
setInitialDraft(undefined);
|
||||
}
|
||||
}, [initialDraft, activeConversationId]);
|
||||
|
||||
// §8.4 — when the project was created with a plugin pinned (the
|
||||
// PluginLoopHome → POST /api/projects path), fetch the immutable
|
||||
// snapshot once so ChatPane can render the active plugin as a
|
||||
// context chip on user messages instead of re-rendering the inline
|
||||
// plugin rail. Re-fetches when the pinned id changes; cancelled if
|
||||
// the project switches away mid-flight to avoid setState-on-unmount.
|
||||
const [activePluginSnapshot, setActivePluginSnapshot] =
|
||||
useState<AppliedPluginSnapshot | null>(null);
|
||||
useEffect(() => {
|
||||
const snapshotId = project.appliedPluginSnapshotId;
|
||||
if (!snapshotId) {
|
||||
setActivePluginSnapshot(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fetchAppliedPluginSnapshot(snapshotId).then((snap) => {
|
||||
if (cancelled) return;
|
||||
setActivePluginSnapshot(snap);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [project.appliedPluginSnapshotId]);
|
||||
useEffect(() => {
|
||||
if (project.pendingPrompt) onClearPendingPrompt();
|
||||
}, [project.pendingPrompt, onClearPendingPrompt]);
|
||||
|
|
@ -1905,6 +1949,7 @@ export function ProjectView({
|
|||
onProjectMetadataChange={(metadata) => {
|
||||
onProjectChange({ ...project, metadata });
|
||||
}}
|
||||
activePluginSnapshot={activePluginSnapshot}
|
||||
/>
|
||||
) : (
|
||||
<div className="pane" data-testid="chat-pane-loading">
|
||||
|
|
|
|||
|
|
@ -754,6 +754,58 @@ code {
|
|||
}
|
||||
.msg.user .role::before { content: ''; }
|
||||
.msg.user .user-text { white-space: pre-wrap; color: var(--text); }
|
||||
.msg-plugin-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 4px 0 6px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 11.5px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
max-width: 100%;
|
||||
}
|
||||
.msg-plugin-chip__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.msg-plugin-chip__label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-plugin-chip__kind {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.msg-plugin-chip__title {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-plugin-chip__version,
|
||||
.msg-plugin-chip__task {
|
||||
color: var(--text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.msg-plugin-chip__task {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: 8px;
|
||||
}
|
||||
.msg.assistant .prose { margin-top: 4px; }
|
||||
.msg .artifact-badge {
|
||||
display: inline-block;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
// the UI can stay rendered when the daemon is briefly unreachable.
|
||||
|
||||
import type {
|
||||
AppliedPluginSnapshot,
|
||||
ApplyResult,
|
||||
ImportFolderRequest,
|
||||
ImportFolderResponse,
|
||||
|
|
@ -393,6 +394,24 @@ export async function applyPlugin(
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch the immutable snapshot pinned to a project / conversation.
|
||||
// Used by ProjectView to surface the active plugin as a context chip
|
||||
// on user messages instead of re-rendering the inline plugin rail
|
||||
// (the user already picked a plugin on Home — re-prompting is noise).
|
||||
export async function fetchAppliedPluginSnapshot(
|
||||
snapshotId: string,
|
||||
): Promise<AppliedPluginSnapshot | null> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/applied-plugins/${encodeURIComponent(snapshotId)}`,
|
||||
);
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as AppliedPluginSnapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the brief that the composer should display for the active
|
||||
// applied plugin. Substitutes `{{var}}` placeholders inside
|
||||
// useCase.query against the user-supplied inputs map; missing values
|
||||
|
|
|
|||
|
|
@ -173,4 +173,63 @@ describe('ProjectView daemon cleanup', () => {
|
|||
expect(resolveSucceededRunStatus('failed')).toBe('failed');
|
||||
expect(resolveSucceededRunStatus('canceled')).toBe('canceled');
|
||||
});
|
||||
|
||||
// Regression: a phantom 'running' row in DB (no runId, no matching active
|
||||
// daemon run) used to stick the UI on "Waiting for first output —
|
||||
// Working 24m+" forever. The reattach loop now self-heals by marking
|
||||
// such a message as failed so the composer is interactive again.
|
||||
it('self-heals running messages with no runId when daemon has no active run', async () => {
|
||||
const startedAt = Date.now();
|
||||
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
|
||||
listMessages.mockResolvedValue([
|
||||
{
|
||||
id: 'msg-phantom',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: startedAt,
|
||||
startedAt,
|
||||
runStatus: 'running',
|
||||
},
|
||||
]);
|
||||
fetchPreviewComments.mockResolvedValue([]);
|
||||
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
|
||||
fetchProjectFiles.mockResolvedValue([]);
|
||||
fetchLiveArtifacts.mockResolvedValue([]);
|
||||
fetchSkill.mockResolvedValue(null);
|
||||
fetchDesignSystem.mockResolvedValue(null);
|
||||
getTemplate.mockResolvedValue(null);
|
||||
listActiveChatRuns.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<ProjectView
|
||||
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
|
||||
routeFileName={null}
|
||||
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
|
||||
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
|
||||
skills={[]}
|
||||
designSystems={[]}
|
||||
daemonLive
|
||||
onModeChange={() => {}}
|
||||
onAgentChange={() => {}}
|
||||
onAgentModelChange={() => {}}
|
||||
onRefreshAgents={() => {}}
|
||||
onOpenSettings={() => {}}
|
||||
onBack={() => {}}
|
||||
onClearPendingPrompt={() => {}}
|
||||
onTouchProject={() => {}}
|
||||
onProjectChange={() => {}}
|
||||
onProjectsRefresh={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(listActiveChatRuns).toHaveBeenCalled());
|
||||
await waitFor(() => {
|
||||
const failedCall = saveMessage.mock.calls.find(
|
||||
(call) =>
|
||||
call[2]?.id === 'msg-phantom' && call[2]?.runStatus === 'failed',
|
||||
);
|
||||
expect(failedCall).toBeTruthy();
|
||||
});
|
||||
expect(reattachDaemonRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@ export interface Project {
|
|||
status?: ProjectStatusInfo;
|
||||
pendingPrompt?: string;
|
||||
metadata?: ProjectMetadata;
|
||||
// Plan §3.A1 / spec §11.5 — set when the project was created with a
|
||||
// plugin pinned (e.g. via PluginLoopHome on the web entry). Lets the
|
||||
// UI hide the in-composer plugin rail and render the active plugin
|
||||
// as context on user messages instead of re-prompting the user to
|
||||
// pick a plugin they already selected.
|
||||
appliedPluginSnapshotId?: string;
|
||||
}
|
||||
|
||||
export interface ProjectTemplate {
|
||||
|
|
|
|||
Loading…
Reference in a new issue