Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b4f3c14
Redesign Shell sidebar collapse, refactor Composer, and add Portal pr…
ankit-thesys Mar 5, 2026
425f1df
Merge branch 'main' of https://github.com/ankit-thesys/openui-comp in…
ankit-thesys Mar 5, 2026
f56c123
Merge branch 'main' into feature/shell-sidebar-composer-redesign
ankit-thesys Mar 11, 2026
1617b98
Merge branch 'main' of https://github.com/ankit-thesys/openui-comp in…
ankit-thesys Mar 11, 2026
3d2df1d
Merge branch 'feature/shell-sidebar-composer-redesign' of https://git…
ankit-thesys Mar 11, 2026
dc2ff4d
removing the attachment process
ankit-thesys Mar 11, 2026
d2f7c91
Merge branch 'main' into feature/shell-sidebar-composer-redesign
ankit-thesys Mar 12, 2026
3876ddb
feat: add artifact system with store, hooks, UI components, and folde…
ankit-thesys Mar 14, 2026
530be22
Merge branch 'main' of https://github.com/ankit-thesys/openui-comp in…
ankit-thesys Mar 16, 2026
6a3a690
feat: expose header customization props for all chat layouts
ankit-thesys Mar 16, 2026
d1d6ce9
format fix
ankit-thesys Mar 16, 2026
cf57a77
Merge branch 'feature/shell-sidebar-composer-redesign' of https://git…
ankit-thesys Mar 16, 2026
1735a2d
Merge branch 'main' of https://github.com/ankit-thesys/openui-comp in…
ankit-thesys Mar 20, 2026
ab9eda4
cleanup
ankit-thesys Mar 20, 2026
8731bfd
personal ai workflow
ankit-thesys Mar 20, 2026
f4b970d
unused import
ankit-thesys Mar 20, 2026
86776b1
pnpm lock
ankit-thesys Mar 20, 2026
d6d3be4
Merge branch 'main' of https://github.com/thesysdev/openui into featu…
ankit-thesys Mar 23, 2026
a9e38ed
PR Review Fixes
ankit-thesys Mar 23, 2026
8d4ae53
format:fix
ankit-thesys Mar 23, 2026
5b3987d
use artifact test removed
ankit-thesys Mar 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
Claude.md
.claude/
CLAUDE.local.md
.workspace/

# Dependencies
node_modules
Expand Down
1 change: 0 additions & 1 deletion packages/react-headless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
},
"peerDependencies": {
"react": ">=19.0.0",
"react-dom": ">=19.0.0",
"zustand": "^4.5.5"
},
"devDependencies": {
Expand Down
46 changes: 46 additions & 0 deletions packages/react-headless/src/hooks/useActiveArtifact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useCallback } from "react";
import { useStore } from "zustand";
import { useArtifactStore } from "../store/ArtifactContext";

/**
* Return type for {@link useActiveArtifact}.
*
* @category Hooks
*/
type UseActiveArtifactReturn = {
/** Whether any artifact is currently active (panel is open). */
isArtifactActive: boolean;
/** The ID of the currently active artifact, or `null` if none. */
activeArtifactId: string | null;
/** Closes whichever artifact is currently active. No-op if none is active. */
closeArtifact: () => void;
};

/**
* Returns global artifact activation state — whether *any* artifact is open,
* and a close action that dismisses it.
*
* Use this in layout components that react to artifact presence (resizing panels,
* showing overlays) without needing to know *which* artifact is active.
* For per-artifact state and actions, use {@link useArtifact} instead.
*
* Must be called within a `<ChatProvider>`.
*
* @category Hooks
* @returns {@link UseActiveArtifactReturn}
*/
export function useActiveArtifact(): UseActiveArtifactReturn {
const store = useArtifactStore();

const activeArtifactId = useStore(store, (s) => s.activeArtifactId);
const isArtifactActive = activeArtifactId !== null;

const closeArtifact = useCallback(() => {
const state = store.getState();
if (state.activeArtifactId) {
state.closeArtifact(state.activeArtifactId);
}
}, [store]);

return { isArtifactActive, activeArtifactId, closeArtifact };
}
69 changes: 69 additions & 0 deletions packages/react-headless/src/hooks/useArtifact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useCallback } from "react";
import { useStore } from "zustand";
import { useArtifactStore } from "../store/ArtifactContext";

/**
* Return type for {@link useArtifact}.
*
* @category Hooks
*/
type UseArtifactReturn = {
/** Whether this artifact is the currently active (visible) one. */
isActive: boolean;
/** Activates this artifact. */
open: () => void;
/** Deactivates this artifact. */
close: () => void;
/** Toggles this artifact: opens if closed, closes if open. */
toggle: () => void;
};

/**
* Binds a component to a specific artifact by ID, providing activation state
* and actions (open, close, toggle).
*
* Multiple `useArtifact` hooks with different IDs can coexist —
* only one artifact is active at a time.
*
* Must be called within a `<ChatProvider>`.
*
* @category Hooks
* @param artifactId - Unique identifier for the artifact
* @returns {@link UseArtifactReturn}
*
* @example
* ```tsx
* function PreviewButton({ id }: { id: string }) {
* const { isActive, toggle } = useArtifact(id);
* return (
* <button onClick={() => toggle()}>
* {isActive ? "Hide" : "Show"} Preview
* </button>
* );
* }
* ```
*/
export function useArtifact(artifactId: string): UseArtifactReturn {
const store = useArtifactStore();

const isActive = useStore(store, (s) => s.activeArtifactId === artifactId);

const open = useCallback(() => {
store.getState().openArtifact(artifactId);
}, [store, artifactId]);

const close = useCallback(() => {
store.getState().closeArtifact(artifactId);
}, [store, artifactId]);

const toggle = useCallback(() => {
const state = store.getState();
if (state.activeArtifactId === artifactId) {
state.closeArtifact(artifactId);
} else {
state.openArtifact(artifactId);
}
}, [store, artifactId]);

return { isActive, open, close, toggle };
}
49 changes: 49 additions & 0 deletions packages/react-headless/src/hooks/useArtifactPortalTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useCallback } from "react";
import { useStore } from "zustand";
import { useArtifactStore } from "../store/ArtifactContext";

/**
* Provides access to the artifact portal target DOM node.
*
* This hook serves two roles:
* - **Registering a portal target:** Call `setNode` from a ref callback to
* designate a DOM element as the render target for artifact content.
* Only one target should be registered at a time.
* - **Reading the portal target:** Read `node` to get the current target
* element for use with `createPortal()`.
*
* Must be called within a `<ChatProvider>`.
*
* @category Hooks
* @returns `{ setNode, node }` — setter for registration, getter for portal rendering
*
* @example
* ```tsx
* // Registering a portal target
* function MyPortalTarget() {
* const { setNode } = useArtifactPortalTarget();
* return <div ref={setNode} />;
* }
*
* // Building a custom artifact panel
* function MyArtifactPanel({ artifactId, children }) {
* const { isActive } = useArtifact(artifactId);
* const { node } = useArtifactPortalTarget();
* if (!isActive || !node) return null;
* return createPortal(<div>{children}</div>, node);
* }
* ```
*/
export function useArtifactPortalTarget() {
const store = useArtifactStore();
const node = useStore(store, (s) => s._artifactPanelNode);

const setNode = useCallback(
(node: HTMLElement | null) => {
store.getState()._setArtifactPanelNode(node);
},
[store],
);

return { setNode, node };
}
6 changes: 6 additions & 0 deletions packages/react-headless/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export { useActiveArtifact } from "./hooks/useActiveArtifact";
export { useArtifact } from "./hooks/useArtifact";
export { useArtifactPortalTarget } from "./hooks/useArtifactPortalTarget";
export { MessageContext, MessageProvider, useMessage } from "./hooks/useMessage";
export { useThread, useThreadList } from "./hooks/useThread";

export { ArtifactContext, useArtifactStore } from "./store/ArtifactContext";
export { ChatProvider } from "./store/ChatProvider";
export {
agUIAdapter,
Expand All @@ -11,6 +15,8 @@ export {
export { openAIConversationMessageFormat, openAIMessageFormat } from "./stream/formats";
export { processStreamedMessage } from "./stream/processStreamedMessage";

export type { ArtifactActions, ArtifactState } from "./store/artifactTypes";

export type {
ChatProviderProps,
ChatStore,
Expand Down
24 changes: 24 additions & 0 deletions packages/react-headless/src/store/ArtifactContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createContext, useContext } from "react";
import type { StoreApi } from "zustand";
import type { ArtifactStore } from "./artifactTypes";

/** @internal React context holding the artifact Zustand store. Provided by `ChatProvider`. */
export const ArtifactContext = createContext<StoreApi<ArtifactStore> | null>(null);

/**
* Returns the raw artifact Zustand store for advanced use cases.
*
* Prefer {@link useArtifact} or {@link useActiveArtifact} for most cases —
* this hook is an escape hatch when you need direct store access.
*
* @category Hooks
* @returns The Zustand `StoreApi<ArtifactStore>` instance
* @throws Error if called outside a `<ChatProvider>`
*/
export const useArtifactStore = (): StoreApi<ArtifactStore> => {
const store = useContext(ArtifactContext);
if (!store) {
throw new Error("useArtifactStore must be used within a <ChatProvider>");
}
return store;
};
23 changes: 20 additions & 3 deletions packages/react-headless/src/store/ChatProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import { useState, type FC } from "react";
import { useEffect, useState, type FC } from "react";
import { ArtifactContext } from "./ArtifactContext";
import { ChatContext } from "./ChatContext";
import { createArtifactStore } from "./createArtifactStore";
import { createChatStore } from "./createChatStore";
import type { ChatProviderProps } from "./types";

export const ChatProvider: FC<ChatProviderProps> = ({ children, ...config }) => {
const [store] = useState(() => createChatStore(config));
const [chatStore] = useState(() => createChatStore(config));
const [artifactStore] = useState(() => createArtifactStore());

return <ChatContext.Provider value={store}>{children}</ChatContext.Provider>;
// Cross-store subscription: reset artifacts when the active thread changes.
// useEffect (not inline) so the cleanup function unsubscribes on unmount.
useEffect(() => {
const unsubscribe = chatStore.subscribe(
(state) => state.selectedThreadId,
() => artifactStore.getState().resetArtifacts(),
);
return unsubscribe;
}, [chatStore, artifactStore]);

return (
<ChatContext.Provider value={chatStore}>
<ArtifactContext.Provider value={artifactStore}>{children}</ArtifactContext.Provider>
</ChatContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from "vitest";
import { createArtifactStore } from "../createArtifactStore";
import { createChatStore } from "../createChatStore";

const flushPromises = () => new Promise((r) => setTimeout(r, 0));

describe("artifact thread-switch cleanup", () => {
const setupStores = () => {
const chatStore = createChatStore({ processMessage: vi.fn() });
const artifactStore = createArtifactStore();

const unsubscribe = chatStore.subscribe(
(state) => state.selectedThreadId,
() => artifactStore.getState().resetArtifacts(),
);

return { chatStore, artifactStore, unsubscribe };
};

it("clears active artifact when selectThread is called", async () => {
const { chatStore, artifactStore, unsubscribe } = setupStores();

artifactStore.getState().openArtifact("art-1");
expect(artifactStore.getState().activeArtifactId).toBe("art-1");

chatStore.getState().selectThread("thread-2");
await flushPromises();

expect(artifactStore.getState().activeArtifactId).toBeNull();

unsubscribe();
});

it("clears active artifact when switchToNewThread is called", async () => {
const { chatStore, artifactStore, unsubscribe } = setupStores();

chatStore.setState({ selectedThreadId: "thread-1" });
artifactStore.getState().openArtifact("art-1");

chatStore.getState().switchToNewThread();
await flushPromises();

expect(artifactStore.getState().activeArtifactId).toBeNull();

unsubscribe();
});

it("clears active artifact when active thread is deleted", async () => {
const deleteThread = vi.fn().mockResolvedValue(undefined);
const chatStore = createChatStore({ deleteThread, processMessage: vi.fn() });
const artifactStore = createArtifactStore();

const unsubscribe = chatStore.subscribe(
(state) => state.selectedThreadId,
() => artifactStore.getState().resetArtifacts(),
);

chatStore.setState({
selectedThreadId: "thread-1",
threads: [
{
id: "thread-1",
title: "Test",
createdAt: new Date().toISOString(),
},
],
});

artifactStore.getState().openArtifact("art-1");

chatStore.getState().deleteThread("thread-1");
await flushPromises();

expect(artifactStore.getState().activeArtifactId).toBeNull();

unsubscribe();
});

it("does not clear active artifact when re-selecting the same thread", async () => {
const { chatStore, artifactStore, unsubscribe } = setupStores();

chatStore.setState({ selectedThreadId: "thread-1" });
await flushPromises();

artifactStore.getState().openArtifact("art-1");
expect(artifactStore.getState().activeArtifactId).toBe("art-1");

chatStore.getState().selectThread("thread-1");
await flushPromises();

expect(artifactStore.getState().activeArtifactId).toBe("art-1");

unsubscribe();
});

it("handles rapid thread switches cleanly", async () => {
const loadThread = vi.fn().mockResolvedValue([]);
const chatStore = createChatStore({ loadThread, processMessage: vi.fn() });
const artifactStore = createArtifactStore();

const unsubscribe = chatStore.subscribe(
(state) => state.selectedThreadId,
() => artifactStore.getState().resetArtifacts(),
);

artifactStore.getState().openArtifact("art-1");

chatStore.getState().selectThread("thread-1");
chatStore.getState().selectThread("thread-2");
chatStore.getState().selectThread("thread-3");
await flushPromises();

expect(artifactStore.getState().activeArtifactId).toBeNull();
expect(chatStore.getState().selectedThreadId).toBe("thread-3");

unsubscribe();
});
});
Loading
Loading