diff --git a/app/src/features/objects/ObjectView.tsx b/app/src/features/objects/ObjectView.tsx index 93c8fd1..ed449ab 100644 --- a/app/src/features/objects/ObjectView.tsx +++ b/app/src/features/objects/ObjectView.tsx @@ -16,6 +16,7 @@ import type { import { CopyableId } from "../../shared/ui/CopyableId"; import { InfiniteScrollSentinel } from "../../shared/ui/InfiniteScrollSentinel"; import { LoadingIndicator } from "../../shared/ui/LoadingIndicator"; +import { PinIcon } from "../../shared/ui/PinIcon"; import { ProfileHoverName } from "../../shared/ui/ProfileHoverCard"; import { CommentaryPanel } from "./components/CommentaryPanel"; import { MarkdownBody } from "./components/MarkdownBody"; @@ -37,6 +38,10 @@ type Props = { onRevealInGraph: () => void; onArchive: () => Promise; onUnarchive: () => Promise; + favorited: boolean; + pinned: boolean; + onSetFavorite: (favorited: boolean) => Promise; + onSetPin: (pinned: boolean) => Promise; onAccessChanged: () => Promise; onUpdate: (input: UpdateObjectRequest) => Promise; }; @@ -297,6 +302,10 @@ export function ObjectView({ onRevealInGraph, onArchive, onUnarchive, + favorited, + pinned, + onSetFavorite, + onSetPin, onAccessChanged, onUpdate, }: Props) { @@ -757,8 +766,6 @@ export function ObjectView({ : `Historical · version ${selectedVersion.version_number}`} - - {isCurrentVersion && ( Updated {formatTimestamp(object.updated_at)} by {updatedBy} @@ -767,6 +774,43 @@ export function ObjectView({
+ {!isArchived && ( + <> + + + + )} + + {!isCurrentVersion && ( + +
+ ))} + + + ); + } return ( ); } diff --git a/app/src/features/workspaces/WorkspaceView.tsx b/app/src/features/workspaces/WorkspaceView.tsx index a1c738d..de7a99a 100644 --- a/app/src/features/workspaces/WorkspaceView.tsx +++ b/app/src/features/workspaces/WorkspaceView.tsx @@ -85,7 +85,7 @@ type Props = { onSetObjectPin: (id: string, pinned: boolean) => Promise; onCreateObject: ( input: CreateObjectRequest, - connectedToObjectId?: string, + connections?: Array<{ objectId: string; direction: "incoming" | "outgoing" }>, ) => Promise<"created" | "connected" | "created_unconnected" | false>; onUpdateObject: (id: string, input: UpdateObjectRequest) => Promise; onArchiveObject: (id: string) => Promise; @@ -94,6 +94,11 @@ type Props = { onArchiveWorkspace: () => Promise; }; +type DraftConnection = { + object: Pick; + direction: "incoming" | "outgoing"; +}; + function hasCurrentVersion(value: ObjectResponse | null): value is CurrentObjectResponse { const metadata = value?.current_version?.metadata; return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata); @@ -162,16 +167,13 @@ export function WorkspaceView({ const [unarchivingObjectId, setUnarchivingObjectId] = useState(null); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [draftObjectTitle, setDraftObjectTitle] = useState("New object"); - const [editorDirty, setEditorDirty] = useState(false); + const [draftConnections, setDraftConnections] = useState([]); + const [editorFormDirty, setEditorFormDirty] = useState(false); const [discardNavigationOpen, setDiscardNavigationOpen] = useState(false); const discardDialogRef = useRef(null); const pendingNavigationRef = useRef<(() => void) | null>(null); const bypassNavigationBlockRef = useRef(false); const [toastMessage, setToastMessage] = useState(null); - const navigationBlocker = useBlocker( - () => editorDirty && !saveLoading && !bypassNavigationBlockRef.current, - ); - const workspaceBasePath = `/w/${workspace.id}`; const view = location.pathname === `${workspaceBasePath}/favorites` @@ -208,6 +210,24 @@ export function WorkspaceView({ title: connectedToObjectId, }) : null; + const initialConnectionKey = connectedToObjectId ? `incoming:${connectedToObjectId}` : ""; + const draftConnectionKey = draftConnections + .map((connection) => `${connection.direction}:${connection.object.id}`) + .sort() + .join(","); + const draftConnectionsDirty = + editorMode === "create" && draftConnectionKey !== initialConnectionKey; + const editorDirty = editorFormDirty || draftConnectionsDirty; + const navigationBlocker = useBlocker( + () => editorDirty && !saveLoading && !bypassNavigationBlockRef.current, + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: A new navigation entry starts a new draft connection set. + useEffect(() => { + setDraftConnections( + connectedFromObject ? [{ object: connectedFromObject, direction: "incoming" }] : [], + ); + }, [editorMode, location.key]); const searchQuery = searchParams.get("q") ?? ""; const includeSearchHistory = searchParams.get("history") === "1"; @@ -262,6 +282,13 @@ export function WorkspaceView({ const canManageWorkspace = workspace.effective_role === "admin"; const unarchiveTarget = archivedObjects.find((object) => object.id === unarchiveTargetId) ?? null; const currentObject = hasCurrentVersion(selectedObject) ? selectedObject : null; + const currentObjectSummary = currentObject + ? (objects.find((object) => object.id === currentObject.object.id) ?? + pinnedObjects.find((object) => object.id === currentObject.object.id) ?? + favoriteObjects.find((object) => object.id === currentObject.object.id) ?? + pinnedFavoriteObjects.find((object) => object.id === currentObject.object.id) ?? + archivedObjects.find((object) => object.id === currentObject.object.id)) + : undefined; const showObjectContextPanel = !objectLoading && editorMode !== "create" && @@ -495,7 +522,7 @@ export function WorkspaceView({ loading={saveLoading} workspaceId={workspace.id} onOpenObject={guardedOpenObject} - onDirtyChange={setEditorDirty} + onDirtyChange={setEditorFormDirty} onTitleChange={setDraftObjectTitle} onCancel={() => { const from = navigationState?.from; @@ -505,13 +532,19 @@ export function WorkspaceView({ setSaveLoading(true); try { - const result = await onCreateObject(input, connectedToObjectId); + const result = await onCreateObject( + input, + draftConnections.map((connection) => ({ + objectId: connection.object.id, + direction: connection.direction, + })), + ); if (result) { setToastMessage( result === "connected" ? "Connected object created" : result === "created_unconnected" - ? "Object created without the connection" + ? "Object created; some connections could not be added" : "Object created", ); } @@ -529,7 +562,7 @@ export function WorkspaceView({ loading={saveLoading} workspaceId={workspace.id} onOpenObject={guardedOpenObject} - onDirtyChange={setEditorDirty} + onDirtyChange={setEditorFormDirty} onCancel={() => guardedNavigate(`${workspaceBasePath}/objects/${currentObject.object.id}`) } @@ -578,6 +611,12 @@ export function WorkspaceView({ setToastMessage("Object restored"); } }} + favorited={Boolean(currentObjectSummary?.favorited)} + pinned={Boolean(currentObjectSummary?.pinned)} + onSetFavorite={(favorited) => + onSetObjectFavorite(currentObject.object.id, favorited) + } + onSetPin={(pinned) => onSetObjectPin(currentObject.object.id, pinned)} onAccessChanged={() => onRefreshObjectAccess(currentObject.object.id)} onUpdate={(input) => onUpdateObject(currentObject.object.id, input)} /> @@ -726,7 +765,7 @@ export function WorkspaceView({ aria-label={`Open ${object.title}`} onClick={() => guardedOpenObject(object.id)} /> - +
- -
- {object.title} - - Updated {formatTimestamp(object.updated_at)} - -
-
+ -
- -
+
+
+ {object.title} + + Updated {formatTimestamp(object.updated_at)} +
))} @@ -854,6 +890,18 @@ export function WorkspaceView({
+ -
@@ -940,16 +977,6 @@ export function WorkspaceView({
- +
@@ -1239,7 +1277,7 @@ export function WorkspaceView({ const action = pendingNavigationRef.current; pendingNavigationRef.current = null; setDiscardNavigationOpen(false); - setEditorDirty(false); + setEditorFormDirty(false); if (navigationBlocker.state === "blocked") { navigationBlocker.proceed(); @@ -1350,8 +1388,17 @@ export function WorkspaceView({ + setDraftConnections((current) => [...current, connection]) + } + onRemoveConnection={(objectId) => + setDraftConnections((current) => + current.filter((connection) => connection.object.id !== objectId), + ) + } /> )} diff --git a/app/src/features/workspaces/hooks/useWorkspaceController.ts b/app/src/features/workspaces/hooks/useWorkspaceController.ts index 15905dc..1748569 100644 --- a/app/src/features/workspaces/hooks/useWorkspaceController.ts +++ b/app/src/features/workspaces/hooks/useWorkspaceController.ts @@ -681,35 +681,45 @@ export function useWorkspaceController({ async function handleCreateObject( input: CreateObjectRequest, - connectedToObjectId?: string, + connections: Array<{ objectId: string; direction: "incoming" | "outgoing" }> = [], ): Promise<"created" | "connected" | "created_unconnected" | false> { const mutation = await runWorkspaceMutation(async (workspaceId) => { const response = await kival.createObject({ workspaceId, input }); - let connectionError: string | null = null; - - if (connectedToObjectId) { - try { - await kival.createObjectEdge({ + const connectionResults = await Promise.allSettled( + connections.map((connection) => + kival.createObjectEdge({ workspaceId, input: { - source_object_id: connectedToObjectId, - target_object_id: response.object.id, + source_object_id: + connection.direction === "incoming" ? connection.objectId : response.object.id, + target_object_id: + connection.direction === "incoming" ? response.object.id : connection.objectId, }, - }); - } catch (cause) { - connectionError = cause instanceof Error ? cause.message : String(cause); + }), + ), + ); + const successfulConnections = connections.filter( + (_connection, index) => connectionResults[index]?.status === "fulfilled", + ); + const connectionErrors = connectionResults.flatMap((result) => { + if (result.status === "fulfilled") { + return []; } - } - return { response, connectionError }; + const cause = result.reason; + return [cause instanceof Error ? cause.message : String(cause)]; + }); + + return { response, successfulConnections, connectionErrors }; }); if (!mutation) { return false; } - const { response, connectionError } = mutation.result; - const connected = Boolean(connectedToObjectId && !connectionError); + const { response, successfulConnections, connectionErrors } = mutation.result; + const hasRequestedConnections = connections.length > 0; + const hasConnectionErrors = connectionErrors.length > 0; objectRefreshControllerRef.current?.abort(); objectRefreshRequestIdRef.current += 1; setObjects((current) => [ @@ -719,7 +729,7 @@ export function useWorkspaceController({ updated_by_display_name: user.display_name, updated_by_workspace_role: workspace?.effective_role, updated_by_object_role: response.effective_role, - connection_count: connected ? 1 : 0, + connection_count: successfulConnections.length, unresolved_thread_count: 0, favorited: false, pinned: false, @@ -729,7 +739,7 @@ export function useWorkspaceController({ ]); setRecentObjects([]); setRecentNextCursor(null); - if (!connectedToObjectId) { + if (!hasRequestedConnections) { setSelectedObject(response); setObjectContext({ backlinks: { @@ -767,22 +777,29 @@ export function useWorkspaceController({ }); } - if (connected) { + if (successfulConnections.length > 0) { + const connectedObjectIds = new Set( + successfulConnections.map((connection) => connection.objectId), + ); setObjects((current) => current.map((object) => - object.id === connectedToObjectId + connectedObjectIds.has(object.id) ? { ...object, connection_count: (object.connection_count ?? 0) + 1 } : object, ), ); } setApplicationError( - connectionError - ? `The object was created, but its connection failed: ${connectionError}` + hasConnectionErrors + ? `The object was created, but ${connectionErrors.length === 1 ? "a connection" : `${connectionErrors.length} connections`} failed: ${connectionErrors.join("; ")}` : null, ); navigate(`/w/${mutation.workspaceId}/objects/${response.object.id}`); - return connectionError ? "created_unconnected" : connected ? "connected" : "created"; + return hasConnectionErrors + ? "created_unconnected" + : hasRequestedConnections + ? "connected" + : "created"; } async function handleUpdateObject( diff --git a/app/src/shared/styles/base.ts b/app/src/shared/styles/base.ts index 87df74c..8afc0a9 100644 --- a/app/src/shared/styles/base.ts +++ b/app/src/shared/styles/base.ts @@ -2,6 +2,17 @@ import type { CSSProperties } from "react"; import { colors, fontFamilies, shadows } from "./constants"; export const baseStyles: Record = { + visuallyHidden: { + position: "absolute", + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + whiteSpace: "nowrap", + border: 0, + }, app: { height: "100vh", minHeight: 0, @@ -27,6 +38,7 @@ export const baseStyles: Record = { background: colors.transparent, color: colors.textSubtle, font: "inherit", + fontFamily: fontFamilies.mono, fontSize: 13, textAlign: "left", cursor: "pointer", @@ -36,6 +48,14 @@ export const baseStyles: Record = { color: colors.textMuted, fontSize: "0.92em", }, + copyableIdIcon: { + width: 28, + height: 28, + display: "grid", + placeItems: "center", + flexShrink: 0, + color: colors.textMuted, + }, profileHoverName: { position: "relative", display: "inline-flex", diff --git a/app/src/shared/styles/objects.ts b/app/src/shared/styles/objects.ts index dc41b59..ee2e5e4 100644 --- a/app/src/shared/styles/objects.ts +++ b/app/src/shared/styles/objects.ts @@ -493,6 +493,17 @@ export const objectsStyles: Record = { flexShrink: 0, pointerEvents: "none", }, + pinnedObjectCardActions: { + position: "absolute", + top: 10, + right: 10, + zIndex: 2, + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 2, + pointerEvents: "none", + }, objectOverviewMain: { position: "relative", minWidth: 0, diff --git a/app/src/shared/ui/CopyableId.tsx b/app/src/shared/ui/CopyableId.tsx index cd24443..423ba08 100644 --- a/app/src/shared/ui/CopyableId.tsx +++ b/app/src/shared/ui/CopyableId.tsx @@ -7,11 +7,18 @@ type Props = { displayValue?: string; label?: string; style?: CSSProperties; + iconOnly?: boolean; }; type CopyStatus = "idle" | "copied" | "failed"; -export function CopyableId({ value, displayValue = value, label = "ID", style }: Props) { +export function CopyableId({ + value, + displayValue = value, + label = "ID", + style, + iconOnly = false, +}: Props) { const [status, setStatus] = useState("idle"); const resetTimeout = useRef | null>(null); @@ -39,21 +46,79 @@ export function CopyableId({ value, displayValue = value, label = "ID", style }: } const feedback = status === "copied" ? "Copied" : status === "failed" ? "Copy failed" : null; + const shortValue = value.slice(0, 8); + const renderedValue = displayValue.includes(value) + ? displayValue.replaceAll(value, value.toUpperCase()) + : displayValue.includes(shortValue) + ? displayValue.replaceAll(shortValue, shortValue.toUpperCase()) + : displayValue; return ( ); }