Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@adobe/data": "0.9.83",
"@adobe/data-react": "0.9.83",
"@ai-sdk/openai-compatible": "^2.0.52",
"@ai-sdk/react": "^3.0.214",
"@aws-sdk/client-s3": "^3.1045.0",
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/app/spike/adobe-data/ChatDatabaseProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { useState, type ReactNode } from 'react';
import { DatabaseProvider } from '@adobe/data-react';
import { createChatDatabase } from '@/state/chat/createChatDatabase';
import { chatStatePlugin } from '@/state/chat/chat-state-plugin';

/**
* SPIKE (@adobe/data adoption evidence) — React binding harness, container half.
*
* `useState(initializer)` (not a module singleton, not `useMemo`) is what makes
* this safe under React 19 StrictMode: the initializer may run twice in
* development, but only the first result is retained, so exactly one Database
* survives per mount and a discarded double-invocation cannot leak observers.
* A module-level singleton would instead be shared across every request on the
* server, which is a cross-tenant state leak — the reason the container is
* created here, inside the client boundary, rather than at import time.
*/
export const ChatDatabaseProvider = ({ children }: { children: ReactNode }) => {
const [handle] = useState(createChatDatabase);
return (
<DatabaseProvider plugin={chatStatePlugin} database={handle.db}>
{children}
</DatabaseProvider>
);
};
89 changes: 89 additions & 0 deletions apps/web/src/app/spike/adobe-data/SpikeChatHarness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use client';

import { useDatabase, useObservableValues } from '@adobe/data-react';
import { chatStatePlugin } from '@/state/chat/chat-state-plugin';

const CONVERSATION_ID = 'spike-conversation';
const PAGE_ID = 'spike-page';

/**
* SPIKE (@adobe/data adoption evidence) — React binding harness, binding half.
*
* Answers the "Next 15 App Router + React 19" spike question in the shape the
* aidd-react skill prescribes: ONE `useObservableValues` call, no other React
* context, actions passed as callbacks, no business logic in the component.
*
* SSR/hydration: `useObservable` seeds `useState(undefined)` and subscribes in
* an effect, so the server render and the first client render both produce
* `values === undefined` — the skeleton branch below. That makes hydration
* mismatch structurally impossible for observable-derived markup, at the cost
* of the first paint always being the skeleton (no server-rendered content for
* this subtree). Any surface that needs server-rendered chat content must get
* it from a prop/RSC payload, not from the Database.
*/
export const SpikeChatHarness = () => {
const db = useDatabase(chatStatePlugin);
const values = useObservableValues(() => ({
entry: db.computed.conversationEntry(CONVERSATION_ID),
streams: db.computed.pageStreams(PAGE_ID),
}));

if (!values) return <p data-testid="spike-skeleton">Loading chat state…</p>;

return (
<section>
<p data-testid="spike-load-status">{values.entry.loadStatus}</p>
<ul data-testid="spike-messages">
{values.entry.messages.map((message) => (
<li key={message.id}>{message.id}</li>
))}
</ul>
<ul data-testid="spike-optimistic">
{values.entry.optimisticSends.map((message) => (
<li key={message.id}>{message.id}</li>
))}
</ul>
<ul data-testid="spike-streams">
{values.streams.map((stream) => (
<li key={stream.messageId}>
{stream.messageId}: {stream.parts.length}
</li>
))}
</ul>
<button
type="button"
data-testid="spike-seed"
onClick={() => db.transactions.seedConversation(CONVERSATION_ID)}
>
Seed conversation
</button>
<button
type="button"
data-testid="spike-send"
onClick={() =>
db.transactions.addOptimisticSend({
conversationId: CONVERSATION_ID,
message: { id: `m-${values.entry.optimisticSends.length + 1}`, role: 'user', parts: [] },
})
}
>
Optimistic send
</button>
<button
type="button"
data-testid="spike-stream"
onClick={() =>
db.transactions.addStream({
messageId: `s-${values.streams.length + 1}`,
pageId: PAGE_ID,
conversationId: CONVERSATION_ID,
triggeredBy: { userId: 'spike', displayName: 'Spike' },
isOwn: true,
})
}
>
Start stream
</button>
</section>
);
};
23 changes: 23 additions & 0 deletions apps/web/src/app/spike/adobe-data/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ChatDatabaseProvider } from './ChatDatabaseProvider';
import { SpikeChatHarness } from './SpikeChatHarness';

/**
* SPIKE (@adobe/data adoption evidence) — evidence route, NOT a product surface.
*
* A server component (RSC) rendering the client Database boundary, so the spike
* exercises the real Next 15 App Router topology: RSC → 'use client' provider →
* binding component reading `computed` observables. Delete with the spike
* branch; it is deliberately unlinked from any navigation.
*/
export const metadata = { title: '@adobe/data spike harness' };

export default function AdobeDataSpikePage() {
return (
<main>
<h1>@adobe/data chat-state harness</h1>
<ChatDatabaseProvider>
<SpikeChatHarness />
</ChatDatabaseProvider>
</main>
);
}
126 changes: 126 additions & 0 deletions apps/web/src/state/chat/__tests__/aiActionUndo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* SPIKE (@adobe/data adoption evidence) — AI actions with atomic undo.
*
* Spike question: "should AI tool-driven edits map to actions→single-transaction
* with the built-in undo/redo stack giving atomic user-facing undo of AI
* changes?" and its companion constraint, "≤1 transaction per action — do
* send/answer/abort corrupt the undo stack?"
*/
import { describe, it, expect } from 'vitest';
import type { UIMessage } from 'ai';
import { Observe } from '@adobe/data/observe';
import { createChatDatabase } from '../createChatDatabase';

const msg = (id: string, text: string): UIMessage => ({
id,
role: 'assistant',
parts: [{ type: 'text', text }],
});

const partsOf = (db: ReturnType<typeof createChatDatabase>['db'], id: string) =>
db.actions.getEntry('c1').messages.find((m) => m.id === id)?.parts;

const seededDatabase = () => {
const handle = createChatDatabase();
handle.db.transactions.applyServerSnapshot({
conversationId: 'c1',
generationToken: 0,
messages: [msg('m1', 'original')],
});
return handle;
};

const readOnce = <T>(observe: Observe<T>): T => {
let captured: { value: T } | null = null;
observe((value) => {
captured = { value };
})();
if (captured === null) throw new Error('observable did not emit synchronously');
return (captured as { value: T }).value;
};

describe('AI action → single undoable transaction', () => {
it('given an AI edit applied through the action, should undo it atomically and leave the original message', () => {
const { db, undoRedo } = seededDatabase();

db.actions.aiApplyEdit({
conversationId: 'c1',
payload: { messageId: 'm1', parts: [{ type: 'text', text: 'ai rewrite' }], editedAt: new Date(0) },
});
expect(partsOf(db, 'm1')).toEqual([{ type: 'text', text: 'ai rewrite' }]);

undoRedo.undo();

expect(partsOf(db, 'm1')).toEqual([{ type: 'text', text: 'original' }]);
});

it('given an undone AI edit, should redo it', () => {
const { db, undoRedo } = seededDatabase();
db.actions.aiApplyEdit({
conversationId: 'c1',
payload: { messageId: 'm1', parts: [{ type: 'text', text: 'ai rewrite' }], editedAt: new Date(0) },
});
undoRedo.undo();

undoRedo.redo();

expect(partsOf(db, 'm1')).toEqual([{ type: 'text', text: 'ai rewrite' }]);
});

it('given two AI edits, should undo only the most recent one (no coalescing)', () => {
const { db, undoRedo } = seededDatabase();
db.actions.aiApplyEdit({
conversationId: 'c1',
payload: { messageId: 'm1', parts: [{ type: 'text', text: 'first' }], editedAt: new Date(0) },
});
db.actions.aiApplyEdit({
conversationId: 'c1',
payload: { messageId: 'm1', parts: [{ type: 'text', text: 'second' }], editedAt: new Date(1) },
});

undoRedo.undo();

expect(partsOf(db, 'm1')).toEqual([{ type: 'text', text: 'first' }]);
});

it('given ordinary chat traffic (sends, stream frames, loads), should record nothing on the undo stack', () => {
const { db, undoRedo } = seededDatabase();

db.transactions.addOptimisticSend({ conversationId: 'c1', message: msg('m2', 'hello') });
db.transactions.addStream({
messageId: 's1',
pageId: 'p1',
conversationId: 'c1',
triggeredBy: { userId: 'u1', displayName: 'Alice' },
isOwn: true,
});
db.transactions.appendPart({ messageId: 's1', part: { type: 'text', text: 'tok' } });
db.transactions.seedConversation('c2');

expect(readOnce(undoRedo.undoEnabled)).toBe(false);
});

it('given an AI edit surrounded by ordinary chat traffic, should undo the AI edit and nothing else', () => {
const { db, undoRedo } = seededDatabase();
db.transactions.addOptimisticSend({ conversationId: 'c1', message: msg('m2', 'hello') });

db.actions.aiApplyEdit({
conversationId: 'c1',
payload: { messageId: 'm1', parts: [{ type: 'text', text: 'ai rewrite' }], editedAt: new Date(0) },
});
db.transactions.addStream({
messageId: 's1',
pageId: 'p1',
conversationId: 'c1',
triggeredBy: { userId: 'u1', displayName: 'Alice' },
isOwn: true,
});

undoRedo.undo();

expect(partsOf(db, 'm1')).toEqual([{ type: 'text', text: 'original' }]);
expect(db.actions.getEntry('c1').optimisticSends.map((m) => m.id)).toEqual(['m2']);
expect(db.actions.getStream('s1')).not.toBeNull();
expect(readOnce(undoRedo.undoEnabled)).toBe(false);
});
});
Loading