mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators use connector setup, collection headers, empty states, and agent lists to manage that work. > - Provider redirects replace the setup page, and collection controls use different sizes. > - Agent selection inserts a bar above the list. The row link highlights only part of the row. > - This pull request keeps sign-in in a separate window and applies the Tasks control sizes to collection and audit headers. > - It also reuses artifact artwork for empty collections and gives agent selection a fixed action card. > - These changes preserve the operator's place and keep controls and rows stable. ## Linked Issues or Issue Description **What happened?** Connector sign-in replaced the current page. Inline collection controls had different heights. Projects and Artifacts used small placeholder empty states. Agent selection moved the list, hover covered only the identity cell, and chat/status shared that cell without matching headings. **Expected behavior** Provider sign-in opens in a new tab or window. Collection controls match Tasks. Empty collections use the large artifact graphics. Agent actions float above the page while rows remain aligned. **Steps to reproduce** Start connector sign-in from a page or dialog. Compare collection and Audit headers. Open an empty Projects or Artifacts collection. Select an agent, then hover across its row. Related public work: #13584 covers an earlier connector new-tab implementation, including same-page fallbacks that this bundle does not use. #8528 covers Tasks toggle sizing. #17742 covers scoped agent Audit sections. This bundle does not change server callback or audit authorization contracts. ## What Changed - Reserve a separate authorization window during the click. Use validated sign-in URLs, retain a new-tab fallback, and close blank windows after failed requests. Store Cloud reauthentication state in the destination window. - Use the Tasks 44px control foundation for shared collection headers and Audit, Costs, Timeline, Secrets, Teams, Gateways, and agent skill controls. - Use a large artifact stack with an off-white Paperclip image with a dark icon for Projects and artifact Tasks empty states. Use one matching artifact for other types. Set 20px gaps and use the label “Create project”. Action hover and keyboard focus animate the artwork. - Portal agent bulk actions to a fixed card 48px above the bottom safe area. Use quintic entry/exit motion and respect reduced motion. Retain card contents during exit. - Give chat and status separate cells, add a Status heading, use the MessagesSquare chat action with a hover tooltip, and highlight the entire row. - Add linked agent chips and a manager picker to the agent profile. Block self and descendant manager choices. Keep the name crown and custom title inline during edits. - Update design guidance, the connection maps, regression tests, and empty-state Storybook fixtures. ## Verification - Final commit `a41a6c6494a839deaaec1ef7cc50c90078ab6381`: all 55 checks passed, three skipped. [Canonical CI run](https://github.com/paperclipai/paperclip/actions/runs/41999792769) includes all eight browser shards and all server/runner/workspace groups. Greptile: 5/5, zero unresolved review findings. - Workspace typecheck/build passed. Local UI suite passed 11,006 tests in 880 files, plus 542 CLI tests. Subsequent connector callback changes passed all 245 AppsConnect tests, UI typecheck, token gates, and query-error policy checks. Manager cache/error regressions passed four tests. Map checks passed. - Local connector browser suite: 20 passed. All six chat integration shards: 1,931 passed. Workspace B groups passed; database tests passed 313 cases across 72 files with one worker. - Browser preview verified agent manager changes, overview links, inline name/title geometry, empty-state colors, row actions and the chat tooltip. Live provider consent and post-deployment feature verification remain separate from these fixtures. - Preserved failures: ten bulk-roster tests initially missed the portaled action card; their selectors were fixed and all twelve roster tests passed. A nested fixture selected pnpm 11 and removed local dependencies; the local shim now pins pnpm 9. Embedded PostgreSQL native links were restored. A Python 3.9 fixture failure passed all 19 tests with the current runtime. Concurrent local database startup failures passed on the one-worker rerun. - The broad local serialized server run passed 3,791 tests with one scheduling assertion failure, which passed in isolation. The local general-server run reproduced a native recovery provider/model fixture failure and has not completed. These server sources are unchanged by this bundle; all final canonical CI server shards passed. This is not a claim that every broad local run passed. - [Earlier CI run](https://github.com/paperclipai/paperclip/actions/runs/41995119880) failed a browser test during cleanup when heartbeat-run cancellation hit a PostgreSQL lock timeout. All final browser shards passed without weakening that test. - Review follow-ups cover Slack popup/fallback recovery, a single artwork hover transform, shared manager error rendering, detached standalone OAuth windows, and verified task-bound callbacks in both hosts. ## Risks - Browser popup policy and provider callback behavior can affect sign-in completion. The original setup page stays open; blocked windows have an explicit recovery path. - Shared toolbar styles affect several surfaces. Menu content remains in its existing portals; canonical label tabs and search clear buttons retain their own geometry. - No schema, server, API authorization, or migration change. - Required CI and review are complete. Deployment provenance and hosted feature verification are checked separately after merge. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, test execution, and browser inspection. The exact model ID and context-window size were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run relevant tests locally and they pass (broader local exceptions are recorded above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
562 lines
20 KiB
TypeScript
562 lines
20 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, useId } from "react";
|
|
import { cn } from "../lib/utils";
|
|
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
|
import { ApiError } from "../api/client";
|
|
import { useDurableAutosave } from "../hooks/useDurableAutosave";
|
|
import { useDraftOwner } from "../hooks/useDraftOwner";
|
|
import { loadStructuredDraft, saveStructuredDraft } from "../lib/composer-draft";
|
|
|
|
type StoredInlineDraft = { version: 1; draft: string; base: string };
|
|
import { FoldCurtain } from "./FoldCurtain";
|
|
|
|
interface InlineEditorProps {
|
|
value: string;
|
|
onSave: (value: string) => void | Promise<unknown>;
|
|
as?: "h1" | "h2" | "p" | "span";
|
|
className?: string;
|
|
placeholder?: string;
|
|
multiline?: boolean;
|
|
/** Keep a short heading's editor as wide as its live text, preserving adjacent controls. */
|
|
fitContent?: boolean;
|
|
imageUploadHandler?: (file: File) => Promise<string>;
|
|
/** Called when a non-image file is dropped onto the editor. */
|
|
onDropFile?: (file: File) => Promise<void>;
|
|
mentions?: MentionOption[];
|
|
nullable?: boolean;
|
|
/** When true, long display-mode markdown is clipped with a fade curtain that expands on click. */
|
|
foldable?: boolean;
|
|
/**
|
|
* Optional host-resolved external object metadata. Forwarded to the read-mode
|
|
* `MarkdownBody` so resolved URLs render with the inline status icon prefix.
|
|
*/
|
|
externalReferences?: MarkdownExternalReferenceMap;
|
|
/**
|
|
* Mount the multiline editor already in edit mode, focused — for hosts whose
|
|
* own affordance opens the editor (the description bubble's pencil, PAP-375).
|
|
*/
|
|
defaultEditing?: boolean;
|
|
/** Notified when the editor swaps between display and edit mode. */
|
|
onEditingChange?: (editing: boolean) => void;
|
|
/**
|
|
* Keep unsaved edits in browser storage under this key, so a reload or an
|
|
* outage does not lose them. A stored edit is restored and saved on mount.
|
|
*/
|
|
draftKey?: string;
|
|
/** Fresh field read used before replaying a restored draft; cached values are display only. */
|
|
readCurrentValue?: () => Promise<string>;
|
|
}
|
|
|
|
/** Shared padding so display and edit modes occupy the exact same box. */
|
|
const pad = "px-1 -mx-1";
|
|
const markdownPad = "px-1";
|
|
const AUTOSAVE_DEBOUNCE_MS = 900;
|
|
|
|
export function queueContainedBlurCommit(container: HTMLDivElement, onCommit: () => void) {
|
|
let frameId = requestAnimationFrame(() => {
|
|
frameId = requestAnimationFrame(() => {
|
|
frameId = 0;
|
|
const active = document.activeElement;
|
|
if (active instanceof Node && container.contains(active)) return;
|
|
onCommit();
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
if (frameId === 0) return;
|
|
cancelAnimationFrame(frameId);
|
|
frameId = 0;
|
|
};
|
|
}
|
|
|
|
export function InlineEditor(props: InlineEditorProps) {
|
|
const draftOwner = useDraftOwner();
|
|
const [scope, setScope] = useState({ owner: draftOwner, generation: 0 });
|
|
const generation = scope.generation + (scope.owner !== undefined && scope.owner !== draftOwner ? 1 : 0);
|
|
if (scope.owner !== draftOwner) setScope({ owner: draftOwner, generation });
|
|
return <OwnedInlineEditor key={generation} {...props} draftOwner={draftOwner} />;
|
|
}
|
|
|
|
function OwnedInlineEditor({
|
|
value,
|
|
onSave,
|
|
as: Tag = "span",
|
|
className,
|
|
placeholder = "Click to edit...",
|
|
multiline = false,
|
|
fitContent = false,
|
|
nullable = false,
|
|
imageUploadHandler,
|
|
onDropFile,
|
|
mentions,
|
|
foldable = false,
|
|
externalReferences,
|
|
defaultEditing = false,
|
|
onEditingChange,
|
|
draftKey,
|
|
readCurrentValue,
|
|
draftOwner,
|
|
}: InlineEditorProps & { draftOwner: string | undefined }) {
|
|
const [editing, setEditing] = useState(false);
|
|
const editingChangeCallback = useRef(onEditingChange);
|
|
editingChangeCallback.current = onEditingChange;
|
|
useEffect(() => {
|
|
if (multiline || !editing) return;
|
|
const notify = editingChangeCallback.current;
|
|
notify?.(true);
|
|
// Report only an edit that actually opened. This also retires the old
|
|
// account's edit on unmount, without a late save notifying its new owner.
|
|
return () => notify?.(false);
|
|
}, [editing, multiline]);
|
|
const [multilineEditing, setMultilineEditing] = useState(multiline && defaultEditing);
|
|
const [multilineFocused, setMultilineFocused] = useState(false);
|
|
const [draft, setDraft] = useState(value);
|
|
const unownedDraft = useRef<StoredInlineDraft | null>(null);
|
|
const lastPropValueRef = useRef(value);
|
|
const lastDraftKeyRef = useRef(draftKey);
|
|
const restoredBaseRef = useRef<StoredInlineDraft | null>(null);
|
|
const readCurrentValueRef = useRef(readCurrentValue);
|
|
readCurrentValueRef.current = readCurrentValue;
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const markdownRef = useRef<MarkdownEditorRef>(null);
|
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const blurCommitFrameRef = useRef<(() => void) | null>(null);
|
|
const pendingFocusFrameRef = useRef<number | null>(null);
|
|
const justEnteredEditRef = useRef(multiline && defaultEditing);
|
|
const hasBeenFocusedRef = useRef(false);
|
|
const autosave = useDurableAutosave<string>({
|
|
sourceId: `inline-editor:${useId()}`,
|
|
targetKey: draftKey ?? undefined,
|
|
save: async (next) => {
|
|
await onSave(next);
|
|
},
|
|
});
|
|
const { markDirty, clearDirty, reset, run: runAutosave } = autosave;
|
|
const runInlineAutosave = useCallback((next: string) => {
|
|
const restored = restoredBaseRef.current;
|
|
const readCurrent = readCurrentValueRef.current;
|
|
return runAutosave(next, {
|
|
beforeSave: restored && restored.draft.trim() === next && readCurrent ? async () => {
|
|
const current = await readCurrent();
|
|
if (current !== restored.base && current.trim() !== next) {
|
|
throw new ApiError("This changed since you loaded it. Your draft is kept for review.", 409, {});
|
|
}
|
|
} : undefined,
|
|
});
|
|
}, [runAutosave]);
|
|
const autosaveState = autosave.status;
|
|
const autosaveStateRef = useRef(autosaveState);
|
|
autosaveStateRef.current = autosaveState;
|
|
|
|
// Unsaved edits are stored with the value they were based on, so a restored
|
|
// edit never silently overwrites a newer server value, and per signed-in
|
|
// account, so another account in this browser never sees or saves them.
|
|
const storageKey = draftKey && draftOwner !== undefined ? `paperclip:inline-draft:v2:${draftOwner}:${draftKey}` : null;
|
|
const clearStoredDraft = useCallback(() => {
|
|
if (!storageKey) return;
|
|
try {
|
|
localStorage.removeItem(storageKey);
|
|
} catch {
|
|
// Unavailable storage holds nothing to clear.
|
|
}
|
|
}, [storageKey]);
|
|
|
|
// Any path that lands the edit (blur, autosave, a resend after an outage)
|
|
// updates `value`; the stored copy is then no longer needed.
|
|
useEffect(() => {
|
|
if (!storageKey) return;
|
|
const stored = loadStructuredDraft<StoredInlineDraft | null>(storageKey, null);
|
|
if (stored && typeof stored.draft === "string" && stored.draft.trim() === value.trim()) clearStoredDraft();
|
|
}, [clearStoredDraft, storageKey, value]);
|
|
|
|
const changeDraft = useCallback((next: string) => {
|
|
restoredBaseRef.current = null;
|
|
setDraft(next);
|
|
if (draftOwner === undefined) unownedDraft.current = { version: 1, draft: next, base: value };
|
|
if (!storageKey) return;
|
|
if (next.trim() === value.trim()) clearStoredDraft();
|
|
else saveStructuredDraft(storageKey, { version: 1, draft: next, base: value } satisfies StoredInlineDraft);
|
|
}, [clearStoredDraft, draftOwner, storageKey, value]);
|
|
|
|
useEffect(() => {
|
|
const previousValue = lastPropValueRef.current;
|
|
const sameTarget = lastDraftKeyRef.current === draftKey;
|
|
lastPropValueRef.current = value;
|
|
lastDraftKeyRef.current = draftKey;
|
|
if (sameTarget && previousValue === value) return;
|
|
setDraft((currentDraft) => {
|
|
if (sameTarget && (editing || multilineEditing) && currentDraft !== previousValue) return currentDraft;
|
|
return value;
|
|
});
|
|
}, [value, draftKey, editing, multilineEditing]);
|
|
|
|
// An edit that never saved (reload, crash, outage) comes back. It saves on
|
|
// its own only when the server value is still the one it was based on;
|
|
// otherwise it waits in the editor for the user.
|
|
const restoredDraftRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (!storageKey || restoredDraftRef.current === storageKey) return;
|
|
restoredDraftRef.current = storageKey;
|
|
restoredBaseRef.current = null;
|
|
if (unownedDraft.current) {
|
|
if (draft.trim() !== value.trim()) saveStructuredDraft(storageKey, { ...unownedDraft.current, draft });
|
|
else clearStoredDraft();
|
|
unownedDraft.current = null;
|
|
return;
|
|
}
|
|
const stored = loadStructuredDraft<StoredInlineDraft | null>(storageKey, null);
|
|
if (!stored || stored.version !== 1 || typeof stored.draft !== "string" || typeof stored.base !== "string") return;
|
|
if (stored.draft.trim() === value.trim()) {
|
|
clearStoredDraft();
|
|
return;
|
|
}
|
|
restoredBaseRef.current = stored;
|
|
setDraft(stored.draft);
|
|
if (multiline) {
|
|
setMultilineEditing(true);
|
|
onEditingChange?.(true);
|
|
} else {
|
|
setEditing(true);
|
|
}
|
|
if (stored.base === value) void runInlineAutosave(stored.draft.trim());
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [storageKey]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
if (blurCommitFrameRef.current !== null) {
|
|
blurCommitFrameRef.current();
|
|
blurCommitFrameRef.current = null;
|
|
}
|
|
if (pendingFocusFrameRef.current !== null) {
|
|
cancelAnimationFrame(pendingFocusFrameRef.current);
|
|
pendingFocusFrameRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const autoSize = useCallback((el: HTMLTextAreaElement | null) => {
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = `${el.scrollHeight}px`;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (editing && inputRef.current) {
|
|
inputRef.current.focus();
|
|
inputRef.current.select();
|
|
if (inputRef.current instanceof HTMLTextAreaElement) {
|
|
autoSize(inputRef.current);
|
|
}
|
|
}
|
|
}, [editing, autoSize]);
|
|
|
|
useEffect(() => {
|
|
if (!multilineEditing || !multiline) return;
|
|
if (!justEnteredEditRef.current) return;
|
|
justEnteredEditRef.current = false;
|
|
if (pendingFocusFrameRef.current !== null) {
|
|
cancelAnimationFrame(pendingFocusFrameRef.current);
|
|
}
|
|
pendingFocusFrameRef.current = requestAnimationFrame(() => {
|
|
pendingFocusFrameRef.current = null;
|
|
markdownRef.current?.focus();
|
|
});
|
|
return () => {
|
|
if (pendingFocusFrameRef.current !== null) {
|
|
cancelAnimationFrame(pendingFocusFrameRef.current);
|
|
pendingFocusFrameRef.current = null;
|
|
}
|
|
};
|
|
}, [multilineEditing, multiline]);
|
|
|
|
// Once the editor has been focused at least once, it's blurred, and any
|
|
// autosave has settled, swap back to the MarkdownBody preview so inline
|
|
// issue refs render with status + quicklook.
|
|
useEffect(() => {
|
|
if (multilineFocused) {
|
|
hasBeenFocusedRef.current = true;
|
|
return;
|
|
}
|
|
if (!multiline || !multilineEditing) return;
|
|
if (!hasBeenFocusedRef.current) return;
|
|
// Waiting or failed saves keep the editor open, so the text stays visible.
|
|
if (autosaveState !== "idle") return;
|
|
hasBeenFocusedRef.current = false;
|
|
setMultilineEditing(false);
|
|
onEditingChange?.(false);
|
|
}, [multiline, multilineEditing, multilineFocused, autosaveState, onEditingChange]);
|
|
|
|
|
|
// A single-line edit that waited for the connection closes once it saves.
|
|
useEffect(() => {
|
|
if (multiline || !editing || autosaveState !== "saved") return;
|
|
if (document.activeElement === inputRef.current) return;
|
|
setEditing(false);
|
|
}, [autosaveState, editing, multiline]);
|
|
|
|
/** Save the draft if it changed. Never rejects; a failed save keeps the text. */
|
|
const commit = useCallback(async (nextValue = draft) => {
|
|
const valueToSave = nextValue.trim();
|
|
const valueChanged = valueToSave !== value;
|
|
const shouldSave = nullable
|
|
? valueChanged
|
|
: Boolean(valueToSave && valueChanged);
|
|
if (!shouldSave) {
|
|
setDraft(value);
|
|
if (draftKey) clearStoredDraft();
|
|
if (!multiline) setEditing(false);
|
|
return;
|
|
}
|
|
const result = await runInlineAutosave(valueToSave);
|
|
// A single-line edit stays open until it is saved, so its text is not lost.
|
|
if (!multiline && result.kind === "saved") setEditing(false);
|
|
}, [clearStoredDraft, draft, draftKey, multiline, nullable, runInlineAutosave, value]);
|
|
|
|
/** Multiline blur/submit: show autosave indicator when persisting */
|
|
const finalizeMultilineBlurOrSubmit = useCallback(() => {
|
|
const trimmed = draft.trim();
|
|
if (trimmed === value || (!trimmed && !nullable)) {
|
|
reset();
|
|
}
|
|
void commit();
|
|
}, [commit, draft, nullable, reset, value]);
|
|
|
|
const cancelPendingBlurCommit = useCallback(() => {
|
|
if (blurCommitFrameRef.current === null) return;
|
|
blurCommitFrameRef.current();
|
|
blurCommitFrameRef.current = null;
|
|
}, []);
|
|
|
|
const scheduleBlurCommit = useCallback((container: HTMLDivElement) => {
|
|
cancelPendingBlurCommit();
|
|
blurCommitFrameRef.current = queueContainedBlurCommit(container, () => {
|
|
blurCommitFrameRef.current = null;
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
setMultilineFocused(false);
|
|
finalizeMultilineBlurOrSubmit();
|
|
});
|
|
}, [cancelPendingBlurCommit, finalizeMultilineBlurOrSubmit]);
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
if (e.key === "Enter" && !multiline) {
|
|
e.preventDefault();
|
|
void commit();
|
|
}
|
|
if (e.key === "Escape") {
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
reset();
|
|
setDraft(value);
|
|
if (draftKey) clearStoredDraft();
|
|
if (multiline) {
|
|
setMultilineFocused(false);
|
|
setMultilineEditing(false);
|
|
onEditingChange?.(false);
|
|
hasBeenFocusedRef.current = false;
|
|
if (document.activeElement instanceof HTMLElement) {
|
|
document.activeElement.blur();
|
|
}
|
|
} else {
|
|
setEditing(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!multiline) return;
|
|
if (!multilineFocused) return;
|
|
const trimmed = draft.trim();
|
|
// Nullable: empty draft can still be a real edit (clearing); only skip debounce when unchanged or empty is invalid.
|
|
if (trimmed === value || (!trimmed && !nullable)) {
|
|
if (autosaveStateRef.current === "saved") clearDirty();
|
|
else reset();
|
|
return;
|
|
}
|
|
markDirty();
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
autosaveDebounceRef.current = setTimeout(() => {
|
|
void commit(trimmed);
|
|
}, AUTOSAVE_DEBOUNCE_MS);
|
|
|
|
return () => {
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
};
|
|
}, [clearDirty, commit, draft, markDirty, multiline, multilineFocused, nullable, reset, value]);
|
|
|
|
if (multiline) {
|
|
const previewValue = autosaveState === "saved" || autosaveState === "idle" ? draft : value;
|
|
const hasValue = Boolean(previewValue.trim());
|
|
const showEditor = multilineEditing || multilineFocused || !hasValue;
|
|
|
|
if (!showEditor) {
|
|
const enterEditMode = () => {
|
|
if (multilineEditing) return;
|
|
justEnteredEditRef.current = true;
|
|
setMultilineEditing(true);
|
|
onEditingChange?.(true);
|
|
};
|
|
return (
|
|
<div
|
|
className={cn(markdownPad, "rounded transition-colors hover:bg-accent/20")}
|
|
onClick={(event) => {
|
|
if (event.defaultPrevented) return;
|
|
const target = event.target as HTMLElement | null;
|
|
if (target && target.closest("a,button,[data-mention-kind],[data-radix-popper-content-wrapper]")) {
|
|
return;
|
|
}
|
|
enterEditMode();
|
|
}}
|
|
onDragEnter={() => enterEditMode()}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
event.preventDefault();
|
|
enterEditMode();
|
|
}}
|
|
role="textbox"
|
|
aria-multiline="true"
|
|
aria-label={placeholder}
|
|
tabIndex={0}
|
|
>
|
|
{foldable ? (
|
|
<FoldCurtain>
|
|
<MarkdownBody
|
|
className={cn("paperclip-edit-in-place-content", className)}
|
|
externalReferences={externalReferences}
|
|
>
|
|
{previewValue}
|
|
</MarkdownBody>
|
|
</FoldCurtain>
|
|
) : (
|
|
<MarkdownBody
|
|
className={cn("paperclip-edit-in-place-content", className)}
|
|
externalReferences={externalReferences}
|
|
>
|
|
{previewValue}
|
|
</MarkdownBody>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
markdownPad,
|
|
"rounded transition-colors",
|
|
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
|
)}
|
|
onFocusCapture={(event) => {
|
|
// Ignore focus events where the active element isn't actually inside
|
|
// the wrapper (React 19 can emit a synthetic focus after a blur).
|
|
const active = document.activeElement;
|
|
if (!(active instanceof Node) || !event.currentTarget.contains(active)) return;
|
|
cancelPendingBlurCommit();
|
|
setMultilineFocused(true);
|
|
}}
|
|
onBlurCapture={(event) => {
|
|
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
|
if (pendingFocusFrameRef.current !== null) {
|
|
cancelAnimationFrame(pendingFocusFrameRef.current);
|
|
pendingFocusFrameRef.current = null;
|
|
}
|
|
scheduleBlurCommit(event.currentTarget);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<MarkdownEditor
|
|
ref={markdownRef}
|
|
value={draft}
|
|
onChange={changeDraft}
|
|
placeholder={placeholder}
|
|
bordered={false}
|
|
className="bg-transparent"
|
|
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
|
imageUploadHandler={imageUploadHandler}
|
|
onDropFile={onDropFile}
|
|
mentions={mentions}
|
|
onSubmit={() => {
|
|
finalizeMultilineBlurOrSubmit();
|
|
}}
|
|
/>
|
|
<div className="flex min-h-4 items-center justify-end pr-1">
|
|
<span
|
|
className={cn(
|
|
"text-(length:--text-micro) transition-opacity duration-150",
|
|
autosaveState === "error" ? "text-destructive" : "text-muted-foreground",
|
|
autosaveState === "idle" ? "opacity-0" : "opacity-100",
|
|
)}
|
|
role={autosaveState === "error" ? "alert" : "status"}
|
|
>
|
|
{autosave.label ?? "Idle"}
|
|
</span>
|
|
{autosaveState === "stalled" ? <button type="button" className="ml-2 text-xs text-foreground underline" onClick={autosave.resendNow}>Retry saving</button> : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (editing) {
|
|
const editor = (
|
|
<textarea
|
|
ref={inputRef}
|
|
value={draft}
|
|
aria-invalid={autosaveState === "error" || undefined}
|
|
aria-description={autosaveState === "error" ? autosave.label ?? undefined : undefined}
|
|
rows={1}
|
|
aria-label={placeholder}
|
|
onChange={(e) => {
|
|
changeDraft(e.target.value);
|
|
autoSize(e.target);
|
|
}}
|
|
onBlur={() => {
|
|
void commit();
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
className={cn(
|
|
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
|
|
pad,
|
|
className
|
|
)}
|
|
/>
|
|
);
|
|
return fitContent ? (
|
|
<span className="inline-editor-fit">
|
|
<span aria-hidden className={cn("invisible whitespace-pre", pad, className)}>{draft || " "}</span>
|
|
{editor}
|
|
</span>
|
|
) : editor;
|
|
}
|
|
|
|
// Use div instead of Tag when rendering markdown to avoid invalid nesting
|
|
// (e.g. <p> cannot contain the <div>/<p> elements that markdown produces)
|
|
const DisplayTag = value && multiline ? "div" : Tag;
|
|
|
|
return (
|
|
<>
|
|
<DisplayTag
|
|
className={cn(
|
|
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
|
|
pad,
|
|
!value && "text-muted-foreground italic",
|
|
className,
|
|
)}
|
|
onClick={() => setEditing(true)}
|
|
>
|
|
{value || placeholder}
|
|
</DisplayTag>
|
|
{autosaveState === "stalled" ? <button type="button" className="text-xs text-foreground underline" onClick={autosave.resendNow}>Retry saving</button> : null}
|
|
</>
|
|
);
|
|
}
|