newChatArtifact.ts9.4 KBView on GitHub /**
* "New …" from the chat composer — the `+` beside the paperclip.
*
* One click has to leave the user looking at an empty document open beside the chat they are
* already in, attached to it. The three store writes that make that true are here rather than
* in the button, because the agentic-table pill does the same three from a different entry
* point (`startAgenticTable`, which mints a chat of its own first), and two copies of this
* sequence would drift the moment one of them learned a fourth step.
*
* Nothing is awaited on the way in. `files.createChatDocument` takes the id this module mints,
* which is what lets the artifact open in the same tick as the click while the row is still
* being written — see agentic-table.ts for the measurements behind that. The cost is that a
* failed write surfaces after the document is already on screen, which is what `onFailed` is
* for: the optimistic node is dropped so nothing is left editing a row that does not exist.
*/
import { useCedarStore } from '@/modules/store';
/** What the composer's `+` can make in one click, and what each is called when new. */
export const NEW_CHAT_ARTIFACT_NAMES = {
document: 'Untitled document',
table: 'Untitled table',
board: 'Untitled board',
} as const;
export type NewChatArtifactKind = keyof typeof NEW_CHAT_ARTIFACT_NAMES;
/**
* What the agent says the moment each kind opens — the chat's first message.
*
* An empty grid with three unnamed columns, or a blank page, teaches nothing about what it is
* FOR. Each of these is deliberately three beats — what the thing is, what asking for something
* does, and what to ask for — because the third is the one that gets used: a user who has just
* made a table has no idea that "one row per deal that went quiet, with the last touch and the
* owner" is a thing they can say.
*/
export const NEW_CHAT_ARTIFACT_INTROS: Record<NewChatArtifactKind, string> = {
document: `This is a **Cedar document** — a live page you and I both write in. It is open beside this chat, and anything you ask me to put in it lands there as you watch.
Ask for the writing, not the formatting. I pull from your Cedar data — deals, meetings, threads, email — so a request can name the source rather than repeat it.
Things worth asking for:
- "Draft the follow-up plan for Acme here, using the last three meetings"
- "Summarise everything we know about their security review"
- "Turn my notes at the top into a one-pager I could send"
The document is addressable: mention it in any chat, or point an agent at it, and it becomes the thing that gets read or rewritten.`,
table: `This is an **agentic table** — a spreadsheet you and I work in together. It is open beside this chat with three starter columns; rename or retype them from the column menu at any time.
Rows are units of work. Tell me what belongs in the table and I will shape the columns, pull the rows from your Cedar data — deals, contacts, meetings, threads — and then run an agent on every row at once, each one writing straight into its own cells so you can watch it fill.
Things worth asking for:
- "One row per deal that went quiet this week, with the last touch and the owner"
- "Add a column that drafts a follow-up for each row"
- "Research every company here — headcount, funding, and how they fit our ICP"
Cells can hold live Cedar objects rather than only text, and the whole table exports to Excel.`,
board: `This is a **custom kanban board**, open beside this chat. You can change the attributes each card carries, change the columns, and have agents pull from your live conversations for anything you need.
It starts with three placeholder columns. Two buttons run it: **Schema** — the board's attributes, which is what every card can hold, plus the columns themselves — and **Display**, which is which attribute draws the columns and which ones show on the card face. A column is just a value of one attribute, so regrouping the board moves no card.
Say what you want on it and I will design the attributes with you, then write the cards. A field a card names is adopted into the schema, so the board grows to fit the work rather than the other way round.
Boards people build:
- Every feature and bug request from Slack, on one board, with who asked and how often
- Every action item from one conversation, as its own card, kept current after each meeting
- A card per deal that has gone quiet, with the last touch, the owner, and the next step
Open a card and it is a full document: notes, sub-tasks, and whatever the agent has written on it.`,
};
/** The two kinds the file-system route creates directly. A board is `boards.create`'s job. */
export type ChatDocumentType = 'document' | 'table';
/**
* What `files.createChatDocument` hands back — the server's own `FsNode` row.
*
* Spelled out in full rather than as `{ id; path; documentType }`. That narrower shape was a
* lie the settle handler then had to cast its way past, and the cast was doing real work: the
* row it writes into the store REPLACES the optimistic node, so every field the type omitted
* was a field the header and the file tree lost the moment the write landed.
*/
export type CreatedChatNode = {
id: string;
orgId: string;
userId?: string | null;
parentId: string | null;
documentType: string;
path: string;
title: string | null;
emoji?: string | null;
description: string | null;
content?: string;
metadata?: unknown;
lastEditedBy: string;
version: number;
wordCount: number | null;
lastOpened: Date | string | null;
createdAt: Date | string;
updatedAt: Date | string;
};
export type CreateChatDocument = (input: {
id: string;
threadId: string;
threadTitle: string | null;
name: string;
documentType?: ChatDocumentType;
}) => Promise<CreatedChatNode>;
/**
* Put a document in front of the user AND in the chat's context, in one tick.
*
* Attached as well as displayed: the display pointer only decides what the user is looking at,
* and the whole reason to make a document from the composer is to then ask the agent to fill
* it — which needs the document in the thread's context.
*
* `optimistic` seeds a local node so the artifact panel knows the type (and holds its editor
* back) before any query resolves. Skip it where the row already exists server-side.
*/
export function attachChatArtifact(input: {
threadId: string;
documentId: string;
name: string;
documentType: NewChatArtifactKind;
optimistic?: boolean;
}): void {
const store = useCedarStore.getState();
if (input.optimistic) {
store.setDocument({
id: input.documentId,
orgId: '',
userId: null,
parentId: null,
documentType: input.documentType,
path: `user/chats/${input.threadId}/${input.name}`,
title: input.name,
description: null,
content: '',
lastEditedBy: 'human',
version: 1,
wordCount: null,
lastOpened: null,
createdAt: new Date(),
updatedAt: new Date(),
isOptimistic: true,
});
}
store.setSelectedArtifact({ kind: 'file', id: input.documentId });
void store.addContextItem(input.threadId, {
kind: 'file',
id: input.documentId,
label: input.name,
addedBy: 'user',
});
// The guide, as the chat's FIRST message — and only ever the first. A canned explanation
// arriving in the middle of a conversation the user is already having is noise; arriving in
// an empty chat next to a thing they have never seen before, it is the whole orientation.
if (store.getThreadMessages(input.threadId).length === 0) {
store.addMessage(
{ role: 'assistant', type: 'text', content: NEW_CHAT_ARTIFACT_INTROS[input.documentType] },
true,
input.threadId,
);
}
}
export type NewChatArtifactResult = {
documentId: string;
threadId: string;
/** Resolves when the background write settles. Exposed so a test can await it. */
created: Promise<void>;
};
/**
* Create a document or a table in the CURRENT chat and open it as that chat's artifact.
*
* The thread is minted rather than required, exactly as attaching a file mints it: the resting
* state of the chat is a draft with no thread id at all, so gating on one would have the `+`
* do nothing on every fresh chat.
*/
export function startNewChatArtifact({
kind,
createChatDocument,
onFailed,
}: {
kind: ChatDocumentType;
createChatDocument: CreateChatDocument;
onFailed: (error: unknown) => void;
}): NewChatArtifactResult | null {
const store = useCedarStore.getState();
const threadId = store.ensureOpenChatThread();
if (!threadId) return null;
const documentId = crypto.randomUUID();
const name = NEW_CHAT_ARTIFACT_NAMES[kind];
const threadTitle = useCedarStore.getState().threadMap[threadId]?.name ?? null;
attachChatArtifact({ threadId, documentId, name, documentType: kind, optimistic: true });
const created = createChatDocument({
id: documentId,
threadId,
threadTitle,
name,
documentType: kind,
}).then(
(node) => {
// The server's own row replaces the placeholder, which is what clears `isOptimistic` and
// releases the editor to mount against something real. It also carries the name the
// server settled on, which is not the requested one when the chat already held an
// "Untitled table".
useCedarStore.getState().setDocument(node);
},
(error: unknown) => {
useCedarStore.getState().removeDocument(documentId);
onFailed(error);
},
);
return { documentId, threadId, created };
}