From 1311bc2d87056d4071dfdbc7fa3f2a1d9c047aea Mon Sep 17 00:00:00 2001 From: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:22:41 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=20=20-=20Add=20=E2=80=9CCreate=20connected?= =?UTF-8?q?=20object=E2=80=9D=20actions=20to=20graph=20nodes=20and=20objec?= =?UTF-8?q?t=20details=20=20=20-=20Create=20the=20new=20object=20and=20its?= =?UTF-8?q?=20connection=20through=20the=20existing=20APIs=20=20=20-=20Sho?= =?UTF-8?q?w=20the=20context=20pane=20during=20object=20creation=20and=20e?= =?UTF-8?q?diting=20=20=20-=20Preview=20draft=20objects=20and=20pending=20?= =?UTF-8?q?connections=20in=20the=20local=20graph=20=20=20-=20Preserve=20s?= =?UTF-8?q?ource-object=20context=20and=20return=20navigation=20=20=20-=20?= =?UTF-8?q?Report=20connection=20failures=20without=20losing=20the=20newly?= =?UTF-8?q?=20created=20object=20=20=20-=20Keep=20generic=20graph=20creati?= =?UTF-8?q?on=20limited=20to=20empty=20graph=20space?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/features/graph/GraphView.tsx | 83 +++++++--- app/src/features/graph/LocalGraph.tsx | 85 +++++----- app/src/features/objects/ObjectEditor.tsx | 6 + .../objects/components/ContextPanel.tsx | 151 +++++++++++++++++- app/src/features/workspaces/WorkspaceView.tsx | 86 ++++++++-- .../hooks/useWorkspaceController.ts | 123 +++++++++----- app/src/shared/styles/graph.ts | 2 +- 7 files changed, 417 insertions(+), 119 deletions(-) diff --git a/app/src/features/graph/GraphView.tsx b/app/src/features/graph/GraphView.tsx index 9bbbd43..767f3b9 100644 --- a/app/src/features/graph/GraphView.tsx +++ b/app/src/features/graph/GraphView.tsx @@ -21,7 +21,7 @@ type Props = { workspace: Workspace; onOpenObject: (objectId: string) => void; onEditObject: (objectId: string) => void; - onCreateObject: () => void; + onCreateObject: (connectedToObjectId?: string, connectedToObjectTitle?: string) => void; focusObjectId?: string | null; }; @@ -31,7 +31,7 @@ type GraphContextMenu = { node: PositionedNode | null; }; -type GraphContextAction = "open" | "edit" | "create"; +type GraphContextAction = "open" | "edit" | "createConnected" | "create"; type PointerState = { pointerId: number; @@ -229,6 +229,7 @@ export function GraphView({ const [response, setResponse] = useState(null); const [hoveredNode, setHoveredNode] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -370,6 +371,7 @@ export function GraphView({ focusedNeighborIdsRef.current = new Set(); rendererRef.current?.setFocusedNode(null); rendererRef.current?.setSelectedNode(null); + setSelectedNodeId(null); drawLabels(); } @@ -386,6 +388,7 @@ export function GraphView({ focusedNeighborIdsRef.current = neighborIds; renderer.setFocusedNode(node.id, neighborIds); renderer.setSelectedNode(node.id); + setSelectedNodeId(node.id); drawLabels(); }, [drawLabels, getNeighborIds], @@ -465,6 +468,7 @@ export function GraphView({ focusedNodeIdRef.current = null; focusedNeighborIdsRef.current = new Set(); setHoveredNode(null); + setSelectedNodeId(null); graphRef.current = graph; physicsRef.current = new GraphPhysics(graph, layoutOptions); @@ -683,8 +687,8 @@ export function GraphView({ const pointerX = event.clientX - rect.left; const pointerY = event.clientY - rect.top; const node = renderer.pickNode(pointerX, pointerY); - const menuWidth = 156; - const menuHeight = node ? 88 : 36; + const menuWidth = 190; + const menuHeight = node ? 116 : 36; setHighlightedContextAction(null); setContextMenu({ @@ -896,6 +900,20 @@ export function GraphView({ outline: "none", }} /> + {selectedNodeId && ( + + )} Edit object + )} - + {!contextMenu.node && ( + + )} )} diff --git a/app/src/features/graph/LocalGraph.tsx b/app/src/features/graph/LocalGraph.tsx index 9ab0e24..9c1b5c7 100644 --- a/app/src/features/graph/LocalGraph.tsx +++ b/app/src/features/graph/LocalGraph.tsx @@ -11,7 +11,8 @@ import { GraphPhysics } from "./physics"; type Props = { context: ObjectContext; onOpenObject: (objectId: string) => void; - onRevealInGraph: () => void; + onRevealInGraph?: () => void; + showIsolated?: boolean; }; type PointerState = { @@ -306,7 +307,12 @@ function drawLocalLabels( context.restore(); } -export function LocalGraph({ context, onOpenObject, onRevealInGraph }: Props) { +export function LocalGraph({ + context, + onOpenObject, + onRevealInGraph, + showIsolated = false, +}: Props) { const { resolvedTheme } = useTheme(); const graphTheme = graphThemes[resolvedTheme]; const containerRef = useRef(null); @@ -323,6 +329,7 @@ export function LocalGraph({ context, onOpenObject, onRevealInGraph }: Props) { const graph = useMemo(() => createLocalGraph(context), [context]); const currentObjectId = context.graph.root_object_id; const hasGraphLinks = graph.nodes.length > 1; + const shouldRenderGraph = hasGraphLinks || showIsolated; const drawLabels = useCallback(() => { const overlay = overlayRef.current; @@ -383,7 +390,7 @@ export function LocalGraph({ context, onOpenObject, onRevealInGraph }: Props) { }, [currentObjectId, drawLabels]); useEffect(() => { - if (!hasGraphLinks) { + if (!shouldRenderGraph) { return; } @@ -409,7 +416,7 @@ export function LocalGraph({ context, onOpenObject, onRevealInGraph }: Props) { renderer.destroy(); rendererRef.current = null; }; - }, [hasGraphLinks, resolvedTheme]); + }, [resolvedTheme, shouldRenderGraph]); useEffect(() => { rendererRef.current?.setTheme(resolvedTheme); @@ -579,7 +586,7 @@ export function LocalGraph({ context, onOpenObject, onRevealInGraph }: Props) { pointerRef.current = null; } - if (!hasGraphLinks) { + if (!shouldRenderGraph) { return null; } @@ -609,40 +616,42 @@ export function LocalGraph({ context, onOpenObject, onRevealInGraph }: Props) { > - + + + )} )} diff --git a/app/src/features/objects/ObjectEditor.tsx b/app/src/features/objects/ObjectEditor.tsx index d99fead..229d19b 100644 --- a/app/src/features/objects/ObjectEditor.tsx +++ b/app/src/features/objects/ObjectEditor.tsx @@ -20,6 +20,7 @@ type CreateProps = { workspaceId: string; onOpenObject: (objectId: string) => void; onDirtyChange: (dirty: boolean) => void; + onTitleChange?: (title: string) => void; }; type EditProps = { @@ -135,6 +136,11 @@ export function ObjectEditor(props: Props) { body !== props.value.current_version.body || JSON.stringify(metadataObject) !== JSON.stringify(props.value.current_version.metadata) : title.trim().length > 0 || body.length > 0 || metadataProperties.length > 0; + const onTitleChange = props.mode === "create" ? props.onTitleChange : undefined; + + useEffect(() => { + onTitleChange?.(title.trim() || "New object"); + }, [onTitleChange, title]); useEffect(() => { props.onDirtyChange(dirty); diff --git a/app/src/features/objects/components/ContextPanel.tsx b/app/src/features/objects/components/ContextPanel.tsx index af19457..0cbd781 100644 --- a/app/src/features/objects/components/ContextPanel.tsx +++ b/app/src/features/objects/components/ContextPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { kival } from "../../../shared/api"; import { selectSoleResultOnEnter } from "../../../shared/forms"; import { styles } from "../../../shared/styles/index"; @@ -13,9 +13,134 @@ type Props = { objects: ObjectSummary[]; onOpenObject: (objectId: string) => void; onRevealInGraph: (objectId: string) => void; + onCreateConnectedObject: (objectId: string) => void; onContextChanged: (objectId: string) => Promise; }; +type CreationContextPanelProps = { + workspaceId: string; + draftTitle: string; + connectedFrom: Pick | null; + onOpenObject: (objectId: string) => void; +}; + +export function CreationContextPanel({ + workspaceId, + draftTitle, + connectedFrom, + onOpenObject, +}: CreationContextPanelProps) { + const graphContext = useMemo(() => { + const timestamp = new Date().toISOString(); + const draftObjectId = "draft-object"; + const sourceNodes = connectedFrom + ? [ + { + id: connectedFrom.id, + workspace_id: workspaceId, + current_version_id: null, + title: connectedFrom.title, + status: "active" as const, + created_by: null, + created_at: timestamp, + updated_at: timestamp, + distance: 1, + incoming_count: 0, + outgoing_count: 1, + }, + ] + : []; + const sourceEdges = connectedFrom + ? [ + { + id: "draft-edge", + workspace_id: workspaceId, + source_object_id: connectedFrom.id, + target_object_id: draftObjectId, + kind: "relationship" as const, + created_by: null, + created_at: timestamp, + updated_at: timestamp, + }, + ] + : []; + + return { + backlinks: { + object_id: draftObjectId, + incoming_edges: [], + incoming_references: [], + }, + edges: { items: [] }, + graph: { + workspace_id: workspaceId, + root_object_id: draftObjectId, + depth: 1, + direction: "both", + max_nodes: connectedFrom ? 2 : 1, + max_edges: connectedFrom ? 1 : 0, + truncated: false, + truncation: { nodes: false, edges: false }, + nodes: [ + { + id: draftObjectId, + workspace_id: workspaceId, + current_version_id: null, + title: draftTitle, + status: "active", + created_by: null, + created_at: timestamp, + updated_at: timestamp, + distance: 0, + incoming_count: connectedFrom ? 1 : 0, + outgoing_count: 0, + }, + ...sourceNodes, + ], + edges: sourceEdges, + }, + }; + }, [connectedFrom, draftTitle, workspaceId]); + + return ( + + ); +} + export function ContextPanel({ workspaceId, context, @@ -23,6 +148,7 @@ export function ContextPanel({ objects, onOpenObject, onRevealInGraph, + onCreateConnectedObject, onContextChanged, }: Props) { const [creatingConnection, setCreatingConnection] = useState(false); @@ -132,13 +258,24 @@ export function ContextPanel({ {connectionCount === 0 && No connected objects yet.} {!creatingConnection && ( - + + + )} {creatingConnection && ( diff --git a/app/src/features/workspaces/WorkspaceView.tsx b/app/src/features/workspaces/WorkspaceView.tsx index c24d875..a1c738d 100644 --- a/app/src/features/workspaces/WorkspaceView.tsx +++ b/app/src/features/workspaces/WorkspaceView.tsx @@ -24,7 +24,7 @@ import { PinIcon } from "../../shared/ui/PinIcon"; import { ProfileHoverName } from "../../shared/ui/ProfileHoverCard"; import { Toast } from "../../shared/ui/Toast"; import { GraphView } from "../graph/GraphView"; -import { ContextPanel } from "../objects/components/ContextPanel"; +import { ContextPanel, CreationContextPanel } from "../objects/components/ContextPanel"; import { ObjectEditor } from "../objects/ObjectEditor"; import { ObjectView } from "../objects/ObjectView"; import { CommandPalette } from "./components/CommandPalette"; @@ -83,7 +83,10 @@ type Props = { onRefreshWorkspaceAccess: () => Promise; onSetObjectFavorite: (id: string, favorited: boolean) => Promise; onSetObjectPin: (id: string, pinned: boolean) => Promise; - onCreateObject: (input: CreateObjectRequest) => Promise; + onCreateObject: ( + input: CreateObjectRequest, + connectedToObjectId?: string, + ) => Promise<"created" | "connected" | "created_unconnected" | false>; onUpdateObject: (id: string, input: UpdateObjectRequest) => Promise; onArchiveObject: (id: string) => Promise; onUnarchiveObject: (id: string) => Promise; @@ -158,6 +161,7 @@ export function WorkspaceView({ const [unarchiveTargetId, setUnarchiveTargetId] = useState(null); const [unarchivingObjectId, setUnarchivingObjectId] = useState(null); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [draftObjectTitle, setDraftObjectTitle] = useState("New object"); const [editorDirty, setEditorDirty] = useState(false); const [discardNavigationOpen, setDiscardNavigationOpen] = useState(false); const discardDialogRef = useRef(null); @@ -189,6 +193,21 @@ export function WorkspaceView({ : matchPath(`${workspaceBasePath}/objects/:objectId/edit`, location.pathname) ? "edit" : null; + const connectedToObjectId = + editorMode === "create" ? (searchParams.get("connectedTo") ?? undefined) : undefined; + const navigationState = location.state as { + from?: string; + connectedFrom?: { id: string; title: string }; + } | null; + const connectedFromState = navigationState?.connectedFrom; + const connectedFromObject = connectedToObjectId + ? connectedFromState?.id === connectedToObjectId + ? connectedFromState + : (objects.find((object) => object.id === connectedToObjectId) ?? { + id: connectedToObjectId, + title: connectedToObjectId, + }) + : null; const searchQuery = searchParams.get("q") ?? ""; const includeSearchHistory = searchParams.get("history") === "1"; @@ -243,11 +262,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 showContextPanel = + const showObjectContextPanel = !objectLoading && - !editorMode && + editorMode !== "create" && currentObject?.object.status === "active" && objectContext?.backlinks.object_id === currentObject.object.id; + const showCreationContextPanel = !objectLoading && editorMode === "create"; + const showContextPanel = showObjectContextPanel || showCreationContextPanel; const graphMode = view === "graph" && !objectLoading && !editorMode && !selectedObject; useEffect(() => { @@ -475,13 +496,24 @@ export function WorkspaceView({ workspaceId={workspace.id} onOpenObject={guardedOpenObject} onDirtyChange={setEditorDirty} - onCancel={() => guardedNavigate(workspaceBasePath)} + onTitleChange={setDraftObjectTitle} + onCancel={() => { + const from = navigationState?.from; + guardedNavigate(from ?? workspaceBasePath); + }} onSubmit={async (input) => { setSaveLoading(true); try { - if (await onCreateObject(input)) { - setToastMessage("Object created"); + const result = await onCreateObject(input, connectedToObjectId); + if (result) { + setToastMessage( + result === "connected" + ? "Connected object created" + : result === "created_unconnected" + ? "Object created without the connection" + : "Object created", + ); } } finally { setSaveLoading(false); @@ -1025,7 +1057,22 @@ export function WorkspaceView({ onEditObject={(objectId) => guardedNavigate(`${workspaceBasePath}/objects/${objectId}/edit`) } - onCreateObject={() => guardedNavigate(`${workspaceBasePath}/new`)} + onCreateObject={(sourceObjectId, sourceObjectTitle) => { + const target = sourceObjectId + ? `${workspaceBasePath}/new?connectedTo=${encodeURIComponent(sourceObjectId)}` + : `${workspaceBasePath}/new`; + requestNavigation(() => + navigate(target, { + state: { + from: `${location.pathname}${location.search}`, + connectedFrom: + sourceObjectId && sourceObjectTitle + ? { id: sourceObjectId, title: sourceObjectTitle } + : undefined, + }, + }), + ); + }} focusObjectId={searchParams.get("focus")} /> )} @@ -1273,7 +1320,7 @@ export function WorkspaceView({ )} - {showContextPanel && ( + {showObjectContextPanel && ( guardedNavigate(`${workspaceBasePath}/graph?focus=${encodeURIComponent(objectId)}`) } + onCreateConnectedObject={(objectId) => + requestNavigation(() => + navigate(`${workspaceBasePath}/new?connectedTo=${encodeURIComponent(objectId)}`, { + state: { + from: `${location.pathname}${location.search}`, + connectedFrom: currentObject + ? { id: objectId, title: currentObject.current_version.title } + : undefined, + }, + }), + ) + } onContextChanged={onRefreshObjectContext} /> )} + + {showCreationContextPanel && ( + + )} ); diff --git a/app/src/features/workspaces/hooks/useWorkspaceController.ts b/app/src/features/workspaces/hooks/useWorkspaceController.ts index 92f3704..15905dc 100644 --- a/app/src/features/workspaces/hooks/useWorkspaceController.ts +++ b/app/src/features/workspaces/hooks/useWorkspaceController.ts @@ -679,16 +679,37 @@ export function useWorkspaceController({ return true; } - async function handleCreateObject(input: CreateObjectRequest): Promise { - const mutation = await runWorkspaceMutation((workspaceId) => - kival.createObject({ workspaceId, input }), - ); + async function handleCreateObject( + input: CreateObjectRequest, + connectedToObjectId?: string, + ): 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({ + workspaceId, + input: { + source_object_id: connectedToObjectId, + target_object_id: response.object.id, + }, + }); + } catch (cause) { + connectionError = cause instanceof Error ? cause.message : String(cause); + } + } + + return { response, connectionError }; + }); if (!mutation) { return false; } - const response = mutation.result; + const { response, connectionError } = mutation.result; + const connected = Boolean(connectedToObjectId && !connectionError); objectRefreshControllerRef.current?.abort(); objectRefreshRequestIdRef.current += 1; setObjects((current) => [ @@ -698,7 +719,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: 0, + connection_count: connected ? 1 : 0, unresolved_thread_count: 0, favorited: false, pinned: false, @@ -708,44 +729,60 @@ export function useWorkspaceController({ ]); setRecentObjects([]); setRecentNextCursor(null); - setSelectedObject(response); - setObjectContext({ - backlinks: { - object_id: response.object.id, - incoming_edges: [], - incoming_references: [], - }, - edges: { items: [] }, - graph: { - workspace_id: response.object.workspace_id, - root_object_id: response.object.id, - depth: 1, - direction: "both", - max_nodes: 100, - max_edges: 250, - truncated: false, - truncation: { nodes: false, edges: false }, - nodes: [ - { - id: response.object.id, - workspace_id: response.object.workspace_id, - current_version_id: response.object.current_version_id, - title: response.current_version?.title ?? response.object.title, - status: response.object.status, - created_by: response.object.created_by, - created_at: response.object.created_at, - updated_at: response.object.updated_at, - distance: 0, - incoming_count: 0, - outgoing_count: 0, - }, - ], - edges: [], - }, - }); - setApplicationError(null); + if (!connectedToObjectId) { + setSelectedObject(response); + setObjectContext({ + backlinks: { + object_id: response.object.id, + incoming_edges: [], + incoming_references: [], + }, + edges: { items: [] }, + graph: { + workspace_id: response.object.workspace_id, + root_object_id: response.object.id, + depth: 1, + direction: "both", + max_nodes: 100, + max_edges: 250, + truncated: false, + truncation: { nodes: false, edges: false }, + nodes: [ + { + id: response.object.id, + workspace_id: response.object.workspace_id, + current_version_id: response.object.current_version_id, + title: response.current_version?.title ?? response.object.title, + status: response.object.status, + created_by: response.object.created_by, + created_at: response.object.created_at, + updated_at: response.object.updated_at, + distance: 0, + incoming_count: 0, + outgoing_count: 0, + }, + ], + edges: [], + }, + }); + } + + if (connected) { + setObjects((current) => + current.map((object) => + object.id === connectedToObjectId + ? { ...object, connection_count: (object.connection_count ?? 0) + 1 } + : object, + ), + ); + } + setApplicationError( + connectionError + ? `The object was created, but its connection failed: ${connectionError}` + : null, + ); navigate(`/w/${mutation.workspaceId}/objects/${response.object.id}`); - return true; + return connectionError ? "created_unconnected" : connected ? "connected" : "created"; } async function handleUpdateObject( diff --git a/app/src/shared/styles/graph.ts b/app/src/shared/styles/graph.ts index bf901dd..86d2f31 100644 --- a/app/src/shared/styles/graph.ts +++ b/app/src/shared/styles/graph.ts @@ -57,7 +57,7 @@ export const graphStyles: Record = { graphContextMenu: { position: "absolute", zIndex: 30, - width: 156, + width: 190, padding: 3, border: `1px solid ${colors.border}`, borderRadius: 7, From c8c4f12af687314f32c789ea51f8d07b957c6b7a Mon Sep 17 00:00:00 2001 From: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:48:20 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=20=20-=20Add=20connected-object=20creation?= =?UTF-8?q?=20from=20graph=20nodes=20and=20object=20details=20=20=20-=20Sh?= =?UTF-8?q?ow=20the=20context=20pane=20and=20local=20graph=20during=20obje?= =?UTF-8?q?ct=20creation=20and=20editing=20=20=20-=20Allow=20incoming=20an?= =?UTF-8?q?d=20outgoing=20connections=20to=20be=20staged=20during=20creati?= =?UTF-8?q?on=20=20=20-=20Preview=20staged=20connections=20in=20the=20draf?= =?UTF-8?q?t=20local=20graph=20=20=20-=20Persist=20all=20staged=20connecti?= =?UTF-8?q?ons=20after=20creating=20the=20object=20and=20report=20partial?= =?UTF-8?q?=20failures=20=20=20-=20Standardize=20object=20actions=20as=20p?= =?UTF-8?q?in=20=E2=86=92=20favorite=20=E2=86=92=20copy=20ID=20=20=20-=20R?= =?UTF-8?q?eplace=20visible=20object=20UUIDs=20with=20a=20fixed-size=20SVG?= =?UTF-8?q?=20copy=20icon=20=20=20-=20Add=20the=20same=20icon=20actions=20?= =?UTF-8?q?to=20object=20lists,=20pinned=20cards,=20favorites,=20and=20obj?= =?UTF-8?q?ect=20details=20=20=20-=20Display=20pinned-card=20actions=20ver?= =?UTF-8?q?tically=20=20=20-=20Preserve=20unsaved-change=20protection=20fo?= =?UTF-8?q?r=20staged=20connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/features/objects/ObjectView.tsx | 48 +++- .../objects/components/ContextPanel.tsx | 235 +++++++++++++----- app/src/features/workspaces/WorkspaceView.tsx | 173 ++++++++----- .../hooks/useWorkspaceController.ts | 61 +++-- app/src/shared/styles/base.ts | 20 ++ app/src/shared/styles/objects.ts | 11 + app/src/shared/ui/CopyableId.tsx | 73 +++++- 7 files changed, 471 insertions(+), 150 deletions(-) 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 ( ); }