Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0315adb
feat(reskinnable-demo): add the Ledgerline automatic-learning skin
davidmckayv Oct 1, 2026
dc67ab4
feat(reskinnable-demo): add the Intelligence screens at /intelligence
davidmckayv Oct 1, 2026
799d1c8
feat(reskinnable-demo): render Ledgerline's agent results as generati…
davidmckayv Oct 1, 2026
0938539
feat(reskinnable-demo): show the trajectory view inside the Intellige…
davidmckayv Oct 1, 2026
1dac8d5
fix(reskinnable-demo): type the trajectory frame's scroll message
davidmckayv Oct 1, 2026
6a608a0
fix(reskinnable-demo): refuse Ledgerline's ChatGPT approve card while…
davidmckayv Oct 1, 2026
564696e
fix(reskinnable-demo): restore the trajectory view's original formatting
davidmckayv Oct 1, 2026
af90f4e
feat(reskinnable-demo): export eval candidates into a stand-in eval p…
davidmckayv Oct 1, 2026
d4bc3dd
feat(reskinnable-demo): AG-UI events in trajectories, and seeded Ledg…
davidmckayv Oct 1, 2026
99c08ab
feat(reskinnable-demo): raw AG-UI event disclosure in the trajectory …
davidmckayv Oct 1, 2026
e15981d
feat(reskinnable-demo): make Ledgerline's policy fix a detective path…
davidmckayv Oct 1, 2026
32d091b
fix(reskinnable-demo): Intelligence Skills dates, delivery switch, an…
davidmckayv Oct 1, 2026
28148c3
fix(reskinnable-demo): keep Ledgerline's chat header clear and its th…
davidmckayv Oct 1, 2026
8d78169
feat(reskinnable-demo): give Ledgerline's threads drawer its own column
davidmckayv Oct 1, 2026
df3e829
feat(reskinnable-demo): seeded history off today's topic; API recipe …
davidmckayv Oct 2, 2026
37d7ee7
feat(reskinnable-demo): show each network event's summary in the traj…
davidmckayv Oct 2, 2026
c5c2fb5
feat(reskinnable-demo): make Ledgerline's demo task the month-end car…
davidmckayv Oct 2, 2026
23b78aa
feat(reskinnable-demo): /intelligence follows the month-end card clos…
davidmckayv Oct 2, 2026
722f315
fix(reskinnable-demo): rewrite Ledgerline's seeded eval suite for the…
davidmckayv Oct 2, 2026
4b150cc
fix(reskinnable-demo): let Ledgerline's agent finish a learned-skill …
davidmckayv Oct 2, 2026
8de1597
feat(reskinnable-demo): auto-match Ledgerline receipts and make the c…
davidmckayv Oct 2, 2026
f697593
feat(reskinnable-demo): learn close-card-exceptions from the person's…
davidmckayv Oct 2, 2026
57fdc46
fix(reskinnable-demo): polish the Ledgerline close and refresh its sa…
davidmckayv Oct 2, 2026
2bd80bf
feat(reskinnable-demo): export any slice of trajectories as plain data
davidmckayv Oct 5, 2026
2a3be7e
feat(reskinnable-demo): a close status card in Ledgerline's chat, and…
davidmckayv Oct 5, 2026
6a0b3f5
feat(reskinnable-demo): draw Ledgerline's generative UI in the trajec…
davidmckayv Oct 5, 2026
bf570a7
fix(reskinnable-demo): only show what the agent was missing on a fail…
davidmckayv Oct 5, 2026
3bcf109
feat(reskinnable-demo): rebase /intelligence onto Intelligence's work…
davidmckayv Oct 5, 2026
7a71e72
feat(reskinnable-demo): restyle the demo's Intelligence screens on th…
davidmckayv Oct 5, 2026
57aece2
feat(reskinnable-demo): reach any trajectory slice over the API or MC…
davidmckayv Oct 5, 2026
84467b2
fix(reskinnable-demo): keep API and MCP export in the Data export sid…
davidmckayv Oct 5, 2026
2805783
Merge remote-tracking branch 'origin/main' into pr7570-merge
davidmckayv Oct 5, 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
Prev Previous commit
Next Next commit
fix(reskinnable-demo): keep Ledgerline's chat header clear and its th…
…read rail tidy

The Inspector launcher sat over the chat header's swap and close controls
now that Ledgerline docks chat on the right. While the skin is open, an
undragged launcher moves into the sidebar above Reset, and the shared
stored position goes back to the default when the skin is left.

Without Intelligence the runtime neither names nor deletes threads, so
rehearsals piled up as "New chat" rows. A new optional threadList skin
field titles unnamed threads from their first user message and hides
threads from before a time the skin supplies; Ledgerline passes its last
full demo reset, served on /ledger/version.

Cost centers shows the quarter budget as a compact meter and one line,
with the exact figures and pending share in the tooltip.
  • Loading branch information
davidmckayv committed Oct 1, 2026
commit 28148c38b3a215667aa07272c1bebdfaf3b5e87d
1 change: 1 addition & 0 deletions examples/showcases/reskinnable-demo/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ Optional:
| `useRuntimeProperties?` | `() => Record<string, unknown> \| undefined` | Contributes this skin's runtime `properties`. The shell calls it inside `RuntimeProviders` (above `CopilotKitProvider`) and threads the result straight into `CopilotKitProvider`'s `properties` prop — this is how a skin scopes its Intelligence runs / durable memory per end-user without the shell reaching into skin internals. Return a stable/memoized object; banking returns `{ userRole, userId }`. Omit if the skin contributes no runtime identity. Every registered skin sets it (`grep -lE '^\s+useRuntimeProperties[,:]' src/skins/*/skin.tsx`). |
| `useData?` | `() => unknown` | Seed-backed data hook; the shell runs it in `SkinProvider`, components read it via `useSkinData<T>()`. **The in-memory escape hatch — the minority path, with a live worked example.** Derive who takes it, do not trust a list: `grep -l 'useData:' src/skins/*/skin.tsx` names the implementors (`bookstore` today, via `src/skins/bookstore/data/use-data.ts`) and `grep -rn 'omits `useData`\|useData' src/skins/*/skin.tsx` shows the rest recording the omission in a comment. In a skin that omits it `useSkinData<T>()` returns `undefined`, and the skin reads its own ledger through its own context/hook instead — banking `useCreditCards` + `useAuthContext`, logistics `useLogistics()`, people `usePeopleLedger()`, commerce `useCommerceLedger()`, airline `useAirlineLedger()`, keel `useKeelLedger()` + `useKeelDesk()`, exec `useExecLedger()`. For writing one, read the implementor first and templates.md § `data/use-data.ts` second. |
| `layoutDefaults?` | `{ chatSide?, inboxSide?, inboxOpen?, chatWidthPx? }` | This skin's default chat geometry: which side the chat docks on (`"left"` by default), which side of the chat the thread rail sits on, whether the rail starts open, and the chat's default width in px. A skin that sets it gets its own stored layout (`nw-layout-prefs:<id>`) and remembers the rail's open state per skin (`nw-inbox:<id>`), so a presenter's choice on one skin never moves another's. Omit it to keep the shared shell defaults (`ledgerline` sets it). |
| `threadList?` | `{ titleFromFirstMessage?, useHiddenBefore? }` | Tidies this skin's thread rail when it runs without Intelligence, where the runtime neither names nor deletes threads: title an unnamed thread from its first user message (`src/shell/chat/thread-titles.ts`, cached per thread id), and hide threads last active before a time the skin supplies (`ledgerline` passes its last demo reset). Omit it and the rail is unchanged. |

Supporting types (also in the contract file):

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as store from "@/skins/ledgerline/data/store";
import { lastResetAt } from "@/skins/ledgerline/learning/service";

export const dynamic = "force-dynamic";
export const GET = () =>
Response.json(
{ version: store.version() },
{ version: store.version(), resetAt: lastResetAt() },
{ headers: { "cache-control": "no-store" } },
);
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useThreads } from "@copilotkit/react-core/v2";
import { cn } from "@/lib/utils";
import { useSkin } from "@/shell/skin-provider";
import { useChatInbox } from "./chat-inbox-context";
import { useThreadTitles } from "./thread-titles";

const UNTITLED_LABEL = "New chat";
const BUCKET_ORDER = [
Expand Down Expand Up @@ -47,7 +48,7 @@ export function ChatInbox({
} = useChatInbox();

const {
threads,
threads: allThreads,
isLoading,
archiveThread,
deleteThread,
Expand All @@ -60,6 +61,24 @@ export function ChatInbox({
limit: 20,
});

// Optional per-skin tidying (see `threadList` in the skin contract): hide
// threads from before the skin's last reset, and title unnamed threads from
// their first user message.
const hiddenBefore = skin.threadList?.useHiddenBefore?.() ?? null;
const threads =
hiddenBefore === null
? allThreads
: allThreads.filter(
(t) =>
t.id === selectedThreadId ||
Date.parse(t.lastRunAt ?? t.updatedAt) >= hiddenBefore,
);
const derivedTitles = useThreadTitles(
threads,
skin.id,
skin.threadList?.titleFromFirstMessage === true,
);

const handleArchive = (id: string) => {
if (id === selectedThreadId) startNewConversation();
Promise.resolve(archiveThread(id)).catch((err: unknown) => {
Expand Down Expand Up @@ -145,7 +164,8 @@ export function ChatInbox({
</p>
<div className="flex flex-col gap-0.5">
{grouped.get(bucket)!.map((thread) => {
const title = thread.name ?? UNTITLED_LABEL;
const named = thread.name ?? derivedTitles[thread.id];
const title = named ?? UNTITLED_LABEL;
const selected = thread.id === selectedThreadId;
return (
<div
Expand All @@ -156,6 +176,7 @@ export function ChatInbox({
<button
type="button"
aria-current={selected ? "true" : undefined}
title={named ? title : undefined}
onClick={() => selectConversation(thread.id)}
className={cn(
// pr-2, not a reserved 3.5rem gutter for the hover
Expand All @@ -177,8 +198,7 @@ export function ChatInbox({
<span
className={cn(
"truncate",
!thread.name &&
"text-[#6e6e6e] dark:text-[#9b9b9b]",
!named && "text-[#6e6e6e] dark:text-[#9b9b9b]",
)}
>
{title}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { firstUserMessage, titleFromText } from "./thread-titles";

describe("thread titles", () => {
it("keeps a short message whole and cuts a long one at a word", () => {
expect(titleFromText(" Approve Priya's offsite report ")).toBe(
"Approve Priya's offsite report",
);
expect(
titleFromText(
"Approve Priya Raman's Q3 team offsite expense report and reimburse her.",
),
).toBe("Approve Priya Raman's Q3 team offsite…");
});

it("reads the first user message from the thread's events", () => {
const events = [
{
type: "RUN_STARTED",
input: { messages: [{ role: "system", content: "x" }] },
},
{
type: "RUN_STARTED",
input: {
messages: [
{
role: "user",
content: [{ type: "text", text: "What's waiting on me?" }],
},
],
},
},
];
expect(firstUserMessage(events)).toBe("What's waiting on me?");
expect(firstUserMessage([{ type: "RUN_FINISHED" }])).toBeNull();
});
});
126 changes: 126 additions & 0 deletions examples/showcases/reskinnable-demo/src/shell/chat/thread-titles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"use client";

import { useEffect, useState } from "react";

/**
* Titles for unnamed threads, taken from each thread's first user message.
*
* Without Intelligence the runtime never names a thread (its
* `generateThreadNames` is Intelligence-only), so every row would read "New
* chat". A skin that opts in (`threadList.titleFromFirstMessage`) gets the
* first thing the user asked instead. Each thread's events are fetched once
* and the title is cached in localStorage by thread id.
*/

const CACHE_KEY = "nw-thread-titles";
const MAX_CHARS = 44;

function readCache(): Record<string, string> {
try {
const raw = window.localStorage.getItem(CACHE_KEY);
return raw ? (JSON.parse(raw) as Record<string, string>) : {};
} catch {
return {};
}
}

function writeCache(entries: Record<string, string>): void {
try {
window.localStorage.setItem(
CACHE_KEY,
JSON.stringify({ ...readCache(), ...entries }),
);
} catch {
// Storage blocked: titles are re-derived next time.
}
}

/** A short one-line title: collapsed whitespace, cut at a word boundary. */
export function titleFromText(text: string): string {
const flat = text.replace(/\s+/g, " ").trim();
if (flat.length <= MAX_CHARS) return flat;
const cut = flat.slice(0, MAX_CHARS);
const space = cut.lastIndexOf(" ");
return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[\s,.;:]+$/, "")}…`;
}

interface MessageLike {
role?: unknown;
content?: unknown;
}

/** The first user message's text in a thread's AG-UI event log, if any. */
export function firstUserMessage(events: unknown[]): string | null {
for (const event of events) {
const e = event as {
type?: string;
input?: { messages?: MessageLike[] };
};
if (e.type !== "RUN_STARTED") continue;
for (const m of e.input?.messages ?? []) {
if (m.role !== "user") continue;
if (typeof m.content === "string" && m.content.trim()) return m.content;
if (Array.isArray(m.content)) {
const text = (m.content as { type?: string; text?: string }[])
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text)
.join(" ")
.trim();
if (text) return text;
}
}
}
return null;
}

export function useThreadTitles(
threads: { id: string; name?: string | null }[],
agentId: string,
enabled: boolean,
runtimeUrl = "/api/copilotkit",
): Record<string, string> {
const [titles, setTitles] = useState<Record<string, string>>({});
const untitled = enabled
? threads.filter((t) => !t.name?.trim()).map((t) => t.id)
: [];
const key = untitled.join(",");

useEffect(() => {
if (!key) return;
let cancelled = false;
const ids = key.split(",");
void (async () => {
const cache = readCache();
const found: Record<string, string> = {};
const fresh: Record<string, string> = {};
for (const id of ids) {
if (cache[id]) {
found[id] = cache[id];
continue;
}
try {
const res = await fetch(
`${runtimeUrl}/threads/${encodeURIComponent(id)}/events?agentId=${encodeURIComponent(agentId)}`,
);
if (!res.ok) continue;
const body = (await res.json()) as { events?: unknown[] };
const text = firstUserMessage(body.events ?? []);
// Only a real title is cached; a thread with no user message yet is
// asked again on the next render that still lists it untitled.
if (text) found[id] = fresh[id] = titleFromText(text);
} catch {
// Leave the row on its "New chat" label.
}
if (cancelled) return;
}
if (Object.keys(fresh).length) writeCache(fresh);
if (!cancelled && Object.keys(found).length)
setTitles((prev) => ({ ...prev, ...found }));
})();
return () => {
cancelled = true;
};
}, [key, agentId, runtimeUrl]);

return titles;
}
14 changes: 14 additions & 0 deletions examples/showcases/reskinnable-demo/src/shell/skin-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,20 @@ export interface Skin {
/** The chat panel's default width in px. Shell default: 600. */
chatWidthPx?: number;
};
/**
* Optional tidying for this skin's thread rail, for a skin that runs without
* Intelligence (where the runtime names no threads and cannot delete them).
*/
threadList?: {
/** Title an unnamed thread from its first user message instead of "New chat". */
titleFromFirstMessage?: boolean;
/**
* Hide threads last active before this epoch-ms time (e.g. the last demo
* reset), so the rail shows only the current run. Called as a hook inside
* the rail. Return null to show everything.
*/
useHiddenBefore?: () => number | null;
};
}

// NOTE: A skin's AGENT is intentionally NOT part of this client contract.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Pages:
- **People**.
- **⌘K** opens a command palette for reports, people, cost centers and pages.

Chat docks on the right and the threads drawer starts closed (the skin's `layoutDefaults`). The drawer's open state is remembered per skin.
Chat docks on the right and the threads drawer starts closed (the skin's `layoutDefaults`). The drawer's open state is remembered per skin. Threads are titled from their first message, and the drawer shows only conversations since the last full reset (`threadList`), because without Intelligence the runtime can neither name nor delete threads. The CopilotKit Inspector launcher sits in the sidebar above Reset while this skin is open, so it never covers the chat header (`components/inspector-placement.ts`); a position you drag it to is kept.

Sidebar controls:

Expand Down
Loading
Loading