diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 52c4e351..4d316e74 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,7 +2,7 @@
We'd like to try something a little different with this repo.
-Given that coding agents write most underlying code now, we'd prefer PRs in the form of _human-written_
+Given that coding agents write most underlying code now, we'd prefer `feature` PRs in the form of _human-written_
text. This can be quite informal — just run your idea by us in the same way you would a coworker or
friend, say, over Slack. If we're aligned on the change, we're happy to burn our tokens
on the underlying implementation.
@@ -11,4 +11,6 @@ Please do not have AI artificially expand what you'd like to do into a formal pr
Submit changes as a PR adding a `.txt` or `.md` file to the [`adrs/`](./adrs/) folder.
+For bugs, just open an issue. We appreciate this a lot, and will credit you as co-author on the commit if we merge a fix.
+
PS: Report any security vulnerabilities privately — see [`SECURITY.md`](./SECURITY.md), not a public issue.
diff --git a/SECURITY.md b/SECURITY.md
index c0a5bd4d..923cf4ae 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -83,6 +83,29 @@ plaintext credentials while a process is using them. An approval means a human
accepted the displayed action under the information available at that time, not that
the resulting behavior is safe.
+### Deliberately portal-only actions
+
+Three actions are intentionally excluded from the agent self-API, even though the
+web portal offers them. They look like capability-parity gaps in an audit; they are
+walls, not gaps, and should not be "fixed" without revisiting the reasoning here.
+
+- **Admin grant changes.** Granting or revoking org-admin rights happens only in the
+ portal, on an authenticated admin's own turn. If the agent could change grants, a
+ prompt-injected or compromised agent process could escalate its own operator's
+ privileges — or demote everyone else's.
+- **Impersonation.** The agent always acts as the principal resolved for the turn.
+ There is no self-API route to act as a different principal, because every
+ authorization decision downstream keys off that identity; a switchable identity
+ would turn one confused turn into another person's authority.
+- **Command-approval decisions.** Approving a gated command is a human judgment made
+ on the approver's own turn. An agent-reachable approval route would collapse the
+ human-in-the-loop gate into a single model decision, which is exactly what the
+ gate exists to prevent.
+
+The common shape: each is a decision that authorizes _future_ agent behavior, so the
+decision itself must come from outside the agent. Parity work should route around
+these, not through them.
+
### Known limitations
- **Command policy is bypassable.** It classifies shell text and catches configured or
diff --git a/package.json b/package.json
index 17bc5dfc..1cdc313d 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"start": "node --env-file-if-exists=.env src/index.ts",
"dev": "SHUTDOWN_DRAIN_MS=2000 node --env-file-if-exists=.env --watch src/index.ts",
"dev-instance": "bash scripts/dev-instance.sh up",
+ "dev-instance:no-slack": "bash scripts/dev-instance.sh up --no-slack",
"dev-instance:status": "bash scripts/dev-instance.sh status",
"dev-instance:down": "bash scripts/dev-instance.sh down",
"worker": "node --env-file-if-exists=.env src/runs/worker-main.ts",
diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html
index f019ed3b..e7c0367b 100644
--- a/plugins/admin/public/index.html
+++ b/plugins/admin/public/index.html
@@ -3905,6 +3905,72 @@
Base model
>
+
@@ -7044,6 +7110,7 @@
Confirm governance change
$("onboarding-model-save").disabled = false;
$("onboarding-model-key").value = "";
await loadOnboarding();
+ await loadCustomProviders();
setStatus(
"st-onboarding-model",
selected.ok ? "Key and base model saved." : "Key saved, but the base model could not be changed.",
@@ -7065,8 +7132,109 @@
Confirm governance change
return;
}
await loadOnboarding();
+ await loadCustomProviders();
setStatus("st-onboarding-model", "Provider disabled.", "ok");
};
+ let customProvidersLoaded = [];
+ function parseCustomModels(text) {
+ return text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const [id, name, contextWindow, maxTokens] = line.split("|").map((part) => part.trim());
+ const model = { id };
+ if (name) model.name = name;
+ if (contextWindow) model.contextWindow = Number(contextWindow);
+ if (maxTokens) model.maxTokens = Number(maxTokens);
+ return model;
+ });
+ }
+ async function loadCustomProviders() {
+ const res = await api("GET", "/api/custom-providers");
+ if (!res.ok) return;
+ customProvidersLoaded = (res.data?.providers || []).filter((provider) => !provider.disabled);
+ const rows = $("custom-provider-rows");
+ rows.textContent = "";
+ $("custom-provider-empty").hidden = customProvidersLoaded.length > 0;
+ customProvidersLoaded.forEach((provider) => {
+ const tr = document.createElement("tr");
+ const cells = [
+ provider.name + " (" + provider.id + ")",
+ provider.protocol === "anthropic" ? "Anthropic" : "OpenAI",
+ provider.baseUrl,
+ provider.models.map((model) => model.id).join(", "),
+ provider.hasKey ? "set (write-only)" : "none",
+ ];
+ cells.forEach((textContent) => {
+ const td = document.createElement("td");
+ td.textContent = textContent;
+ tr.appendChild(td);
+ });
+ const actions = document.createElement("td");
+ const edit = document.createElement("button");
+ edit.textContent = "Edit";
+ edit.onclick = () => {
+ $("custom-provider-id").value = provider.id;
+ $("custom-provider-name").value = provider.name;
+ $("custom-provider-protocol").value = provider.protocol;
+ $("custom-provider-url").value = provider.baseUrl;
+ $("custom-provider-key").value = "";
+ $("custom-provider-models").value = provider.models
+ .map((model) =>
+ [model.id, model.name, model.contextWindow, model.maxTokens].filter((part) => part != null).join(" | "),
+ )
+ .join("\n");
+ };
+ const remove = document.createElement("button");
+ remove.className = "danger";
+ remove.textContent = "Remove";
+ remove.onclick = async () => {
+ if (!confirm("Remove " + provider.name + "? Its models leave every model picker.")) return;
+ const removed = await api("DELETE", "/api/custom-providers/" + encodeURIComponent(provider.id));
+ if (!removed.ok) {
+ setStatus("st-custom-provider", removed.data?.message || "Could not remove this provider.", "err");
+ return;
+ }
+ await loadCustomProviders();
+ setStatus("st-custom-provider", "Provider removed.", "ok");
+ };
+ actions.appendChild(edit);
+ actions.appendChild(remove);
+ tr.appendChild(actions);
+ rows.appendChild(tr);
+ });
+ }
+ $("custom-provider-save").onclick = async () => {
+ const id = $("custom-provider-id").value.trim();
+ const name = $("custom-provider-name").value.trim();
+ const baseUrl = $("custom-provider-url").value.trim();
+ const models = parseCustomModels($("custom-provider-models").value);
+ if (!id || !name || !baseUrl || models.length === 0) {
+ setStatus("st-custom-provider", "Provider id, name, base URL, and at least one model are required.", "err");
+ return;
+ }
+ const apiKey = $("custom-provider-key").value.trim();
+ const body = {
+ name,
+ protocol: $("custom-provider-protocol").value,
+ baseUrl,
+ models,
+ ...(apiKey ? { apiKey } : {}),
+ ...($("custom-provider-validate").checked ? {} : { validate: false }),
+ };
+ $("custom-provider-save").disabled = true;
+ setStatus("st-custom-provider", "Saving…", "saving", true);
+ const saved = await api("PUT", "/api/custom-providers/" + encodeURIComponent(id), body);
+ $("custom-provider-save").disabled = false;
+ if (!saved.ok) {
+ setStatus("st-custom-provider", saved.data?.message || "Could not save this provider.", "err", true);
+ return;
+ }
+ $("custom-provider-key").value = "";
+ await loadCustomProviders();
+ setStatus("st-custom-provider", "Provider saved. Its models are now in the picker.", "ok");
+ };
function openOnboardingTarget(target) {
setView("connectors");
setTimeout(
diff --git a/plugins/admin/src/index.ts b/plugins/admin/src/index.ts
index f4902bd3..25106783 100644
--- a/plugins/admin/src/index.ts
+++ b/plugins/admin/src/index.ts
@@ -267,6 +267,7 @@ const WRITES = new Map
([
["users", ["PUT", "POST"]],
["slack-installation", ["PUT", "DELETE"]],
["model-providers", ["PUT", "DELETE"]],
+ ["custom-providers", ["PUT", "DELETE"]],
]);
const READS = [
@@ -291,6 +292,7 @@ const READS = [
"ack-emoji-picks",
"slack-installation",
"model-providers",
+ "custom-providers",
];
const server = createServer((req, res) => {
diff --git a/plugins/web-ui/package-lock.json b/plugins/web-ui/package-lock.json
index 9d34b1ac..f433146f 100644
--- a/plugins/web-ui/package-lock.json
+++ b/plugins/web-ui/package-lock.json
@@ -664,7 +664,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -713,7 +712,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -1335,7 +1333,6 @@
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@mariozechner/mini-lit/-/mini-lit-0.2.1.tgz",
"integrity": "sha512-u300euLgCsDDlb8o2Wbz+55eSJga5X2vB58s9XBuFIr2Bi3iI+GMR7t/NYo/O6Vr6obXShXgYjR3SRUJVgo+kQ==",
- "peer": true,
"dependencies": {
"@preact/signals-core": "^1.12.1",
"class-variance-authority": "^0.7.1",
@@ -2955,7 +2952,6 @@
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
"integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==",
"license": "BSD-3-Clause",
- "peer": true,
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
@@ -3193,7 +3189,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -3454,7 +3449,6 @@
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
@@ -3835,7 +3829,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts
index 9a4d275a..de3ec466 100644
--- a/plugins/web-ui/src/chat.ts
+++ b/plugins/web-ui/src/chat.ts
@@ -41,8 +41,13 @@ import {
entriesToMessages,
fetchEntry,
fetchTranscript,
+ currentEarlierCount,
+ forkOriginDetails,
forkCutSeq,
forkSession,
+ inheritedRefreshEntries,
+ inheritedTranscript,
+ loadInheritedTranscript,
makeCoreStreamFn,
makeOpenerStreamFn,
makeRunResumeStreamFn,
@@ -82,16 +87,19 @@ import { browserRenderableImage, formatBytes, icon, relTime } from "./ui";
import { adminSessionLogUrl, appState, can, renderSidebarTop, syncUrlFromState } from "./shell";
import {
addPendingSession,
+ dropPendingSession,
groupDmTitle,
refreshSessions,
renderList,
sessionsState,
sessionSlackUrl,
surfaceOf,
+ openSession,
} from "./sessions";
-import { backgroundLabel, clearWorking, conversationBackground, markWorking } from "./session-list";
+import { backgroundLabel, clearWorking, conversationBackground, isAbandonedNewChat, markWorking } from "./session-list";
import { liveTurnThreadRef } from "./working-dot";
import { newChatDraftKey, saveDraft, storedDraft } from "./drafts";
+import { createForkOriginController, forkOriginView } from "./fork-origin";
installMarkdownSanitizer();
@@ -122,8 +130,13 @@ export function markConnectorConnected(provider: string): void {
for (const hook of redrawHooks) hook();
}
-export function createChatSurface(ctx: ConvCtx): ChatSurface {
+export function createChatSurface(
+ ctx: ConvCtx,
+ dependencies: { fetchTranscript?: typeof fetchTranscript; openSession?: typeof openSession } = {},
+): ChatSurface {
const runSlot = createRunSlot();
+ const transcriptFetcher = dependencies.fetchTranscript ?? fetchTranscript;
+ const sessionOpener = dependencies.openSession ?? openSession;
const chatState = {
agent: null as Agent | null,
@@ -144,7 +157,37 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
transcriptAnchorSeq: null as number | null,
earlierCount: 0,
loadingEarlier: false,
+ forkSession: null as CoreSession | null,
+ inheritedMessages: [] as ReturnType,
+ inheritedExpanded: false,
+ inheritedLoaded: false,
};
+ const forkOriginController = createForkOriginController({
+ state: chatState,
+ load: async () => {
+ const session = chatState.forkSession;
+ if (!session) return [];
+ const entries = await loadInheritedTranscript(session, [], transcriptFetcher);
+ return entriesToMessages(entries, transcriptModel());
+ },
+ navigate: async () => {
+ const sourceId = chatState.forkSession?.forkedFrom?.sessionId;
+ if (!sourceId) return;
+ const listed = sessionsState.list.find((session) => session.id === sourceId);
+ const page = await transcriptFetcher(sourceId, { tailTurns: TAIL_TURNS });
+ const source = listed ?? page.session;
+ if (!source) throw new Error("missing source session");
+ await sessionOpener(source, Promise.resolve(page));
+ },
+ current: () => Boolean(chatState.forkSession && chatState.sessionId === chatState.forkSession.id),
+ redraw: () => {
+ if (chatState.agent) drawActiveChat();
+ else readonlyRedraw?.();
+ },
+ setError: (error) => {
+ ctx.composer.state.error = error;
+ },
+ });
let workTicker: ReturnType | null = null;
let revealedTailLen = 0;
@@ -163,6 +206,7 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
let readOnlyView: { id: string; threadRef: string; session: CoreSession; anchorSeq: number | null } | null = null;
function teardownActiveChat(): void {
+ forkOriginController.invalidateRefresh();
readOnlyView = null;
preserveOutgoingWorkingDot(null);
detachActiveAgent();
@@ -183,6 +227,7 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
}
function resetChatState(): void {
+ dropAbandonedNewChat(null);
teardownActiveChat();
proactiveOpenerStarted = false;
chatState.rememberedThreadRef = null;
@@ -200,12 +245,29 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
const carried = storedDraft(newChatDraftKey(user));
if (carried) saveDraft(threadRef, carried);
ctx.composer.resetComposer();
+ forkOriginController.reset();
mountContinuable(threadRef, null, context?.scopeId ?? null, [], context?.name ?? null);
renderList();
ctx.composer.focusComposerEnd();
return threadRef;
}
+ function dropAbandonedNewChat(nextThreadRef: string | null): void {
+ const ref = chatState.threadRef;
+ if (
+ isAbandonedNewChat({
+ threadRef: ref,
+ nextThreadRef,
+ sessionId: chatState.sessionId,
+ pendingSend: chatState.pendingSend,
+ hasHumanMessage: (chatState.agent?.state.messages ?? []).some((m) => !(m as { opener?: boolean }).opener),
+ draft: ctx.composer.state.draft || storedDraft(ref ?? ""),
+ attachments: ctx.composer.state.attachments.length,
+ })
+ )
+ dropPendingSession(ref!);
+ }
+
function preserveOutgoingWorkingDot(nextThreadRef: string | null): void {
const live = liveTurnThreadRef({
mountedThreadRef: chatState.threadRef,
@@ -228,17 +290,25 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
scopeId: string | null,
messages: ReturnType,
contextName: string | null = null,
+ session?: CoreSession,
+ inheritedMessages: ReturnType = [],
): void {
const container = ctx.claimContainer();
if (!container) return;
readOnlyView = null;
preserveOutgoingWorkingDot(threadRef);
+ dropAbandonedNewChat(threadRef);
detachActiveAgent();
ctx.composer.resetComposer();
+ forkOriginController.reset();
chatState.threadRef = threadRef;
chatState.sessionId = sessionId;
chatState.scopeId = scopeId;
chatState.contextName = contextName;
+ chatState.forkSession = session ?? null;
+ chatState.inheritedMessages = inheritedMessages;
+ chatState.inheritedExpanded = false;
+ chatState.inheritedLoaded = !session?.forkedFrom;
chatState.rememberedThreadRef = threadRef;
chatState.rememberedSessionId = sessionId;
chatState.rememberedScopeId = scopeId;
@@ -377,18 +447,39 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
};
}
+ function inheritedHeader(): TemplateResult | typeof nothing {
+ const origin = chatState.forkSession
+ ? forkOriginDetails(chatState.forkSession, chatState.inheritedLoaded ? chatState.inheritedMessages.length : 0)
+ : null;
+ return forkOriginView(
+ origin
+ ? {
+ title: origin.title,
+ messageCount: origin.messageCount,
+ expanded: chatState.inheritedExpanded,
+ icon: icon(GitFork, 14),
+ navigate: () => void forkOriginController.navigate(),
+ toggle: () => void forkOriginController.toggle().catch(() => {}),
+ }
+ : null,
+ );
+ }
+
function onDelivery(threadRef: string): void {
const ro = readOnlyView;
if (ro && threadRef === ro.threadRef) {
void fetchTranscript(ro.id, ro.anchorSeq !== null ? { sinceSeq: ro.anchorSeq } : { tailTurns: TAIL_TURNS })
.then((page) => {
if (readOnlyView?.id !== ro.id) return;
- const earlier = page.earlierEntries ?? 0;
+ const split = inheritedTranscript(ro.session, page.entries ?? []);
+ const rawEarlier = page.earlierEntries ?? 0;
+ const earlier = currentEarlierCount(ro.session, rawEarlier);
mountReadOnly(
readOnlyView.session,
- entriesToMessages(page.entries ?? [], transcriptModel()),
+ entriesToMessages(split.current, transcriptModel()),
earlier,
- earlier > 0 ? (page.entries?.[0]?.seq ?? null) : null,
+ rawEarlier > 0 ? (page.entries?.[0]?.seq ?? null) : null,
+ entriesToMessages(split.inherited, transcriptModel()),
);
})
.catch(() => {});
@@ -480,13 +571,30 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
async function refreshTranscriptFromEntries(agent: Agent): Promise {
const sessionId = chatState.sessionId;
if (!sessionId || agent !== chatState.agent || agent.state.isStreaming) return drawActiveChat(agent);
+ const generation = forkOriginController.beginRefresh();
const last = agent.state.messages[agent.state.messages.length - 1] as { stopReason?: string } | undefined;
if (last?.stopReason === "error" || last?.stopReason === "aborted") return drawActiveChat(agent);
try {
const anchor = chatState.transcriptAnchorSeq;
- const page = await fetchTranscript(sessionId, anchor !== null ? { sinceSeq: anchor } : undefined);
- if (agent !== chatState.agent || agent.state.isStreaming) return;
- const messages = entriesToMessages(page.entries ?? [], transcriptModel());
+ const page = await transcriptFetcher(sessionId, anchor !== null ? { sinceSeq: anchor } : undefined);
+ if (
+ !forkOriginController.isCurrentRefresh(generation) ||
+ sessionId !== chatState.sessionId ||
+ agent !== chatState.agent ||
+ agent.state.isStreaming
+ )
+ return;
+ const split = inheritedTranscript(chatState.forkSession ?? {}, page.entries ?? []);
+ const messages = entriesToMessages(split.current, transcriptModel());
+ const refreshedInherited = inheritedRefreshEntries(
+ chatState.forkSession ?? {},
+ page.entries ?? [],
+ chatState.inheritedLoaded,
+ );
+ forkOriginController.applyRefresh(
+ generation,
+ refreshedInherited ? entriesToMessages(refreshedInherited, transcriptModel()) : null,
+ );
try {
const r = await api<{ approvals: PendingApproval[] }>(
`/api/sessions/${encodeURIComponent(sessionId)}/approvals`,
@@ -495,10 +603,17 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
} catch {
void 0;
}
- if (agent !== chatState.agent || agent.state.isStreaming) return;
+ if (
+ !forkOriginController.isCurrentRefresh(generation) ||
+ sessionId !== chatState.sessionId ||
+ agent !== chatState.agent ||
+ agent.state.isStreaming
+ )
+ return;
agent.state.messages = messages;
- chatState.earlierCount = page.earlierEntries ?? 0;
- chatState.transcriptAnchorSeq = chatState.earlierCount > 0 ? (page.entries?.[0]?.seq ?? null) : null;
+ const rawEarlier = page.earlierEntries ?? 0;
+ chatState.earlierCount = currentEarlierCount(chatState.forkSession ?? {}, rawEarlier);
+ chatState.transcriptAnchorSeq = rawEarlier > 0 ? (page.entries?.[0]?.seq ?? null) : null;
} catch {
void 0;
}
@@ -627,18 +742,28 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
messages: ReturnType,
earlierCount = 0,
anchorSeq: number | null = null,
+ inheritedMessages: ReturnType = [],
): void {
const container = ctx.claimContainer();
if (!container) return;
preserveOutgoingWorkingDot(s.threadRef);
+ dropAbandonedNewChat(s.threadRef);
detachActiveAgent();
chatState.agent = null;
clearLiveWork();
chatState.host = null;
ctx.composer.resetComposer();
+ const sameSession = chatState.sessionId === s.id && chatState.threadRef === null;
+ if (!sameSession) forkOriginController.reset();
chatState.threadRef = null;
chatState.sessionId = s.id;
chatState.scopeId = s.scopeId;
+ chatState.forkSession = s;
+ if (!(sameSession && chatState.inheritedLoaded)) {
+ chatState.inheritedMessages = inheritedMessages;
+ chatState.inheritedLoaded = !s.forkedFrom;
+ }
+ if (!sameSession) chatState.inheritedExpanded = false;
syncLocation();
resetBackgroundPanel();
@@ -670,6 +795,7 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
${backgroundActivityStrip()}
@@ -742,8 +881,8 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
`;
}
- function setTranscriptWindow(anchorSeq: number | null, earlierCount: number): void {
- chatState.transcriptAnchorSeq = earlierCount > 0 ? anchorSeq : null;
+ function setTranscriptWindow(anchorSeq: number | null, earlierCount: number, hasEarlier = earlierCount > 0): void {
+ chatState.transcriptAnchorSeq = hasEarlier ? anchorSeq : null;
chatState.earlierCount = earlierCount;
if (chatState.agent) drawActiveChat(chatState.agent);
}
@@ -770,14 +909,20 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
try {
const page = await fetchTranscript(sessionId, { beforeSeq: anchor, tailTurns: TAIL_TURNS });
if (agent !== chatState.agent || agent.state.isStreaming) return;
- const earlierMessages = entriesToMessages(page.entries ?? [], transcriptModel());
+ const split = inheritedTranscript(chatState.forkSession ?? {}, page.entries ?? []);
+ const earlierMessages = entriesToMessages(split.current, transcriptModel());
+ if (!chatState.inheritedLoaded)
+ chatState.inheritedMessages = [
+ ...entriesToMessages(split.inherited, transcriptModel()),
+ ...chatState.inheritedMessages,
+ ];
const scroller = chatState.host?.querySelector(".chat-scroll");
const priorHeight = scroller?.scrollHeight ?? 0;
const priorTop = scroller?.scrollTop ?? 0;
agent.state.messages = [...earlierMessages, ...agent.state.messages];
- const remaining = page.earlierEntries ?? 0;
- chatState.transcriptAnchorSeq = remaining > 0 ? (page.entries?.[0]?.seq ?? null) : null;
- chatState.earlierCount = remaining;
+ const rawRemaining = page.earlierEntries ?? 0;
+ chatState.transcriptAnchorSeq = rawRemaining > 0 ? (page.entries?.[0]?.seq ?? null) : null;
+ chatState.earlierCount = currentEarlierCount(chatState.forkSession ?? {}, rawRemaining);
chatState.loadingEarlier = false;
drawActiveChat(agent);
requestAnimationFrame(() => {
@@ -847,12 +992,16 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
function drawActiveChat(agent = chatState.agent, opts: { forceScroll?: boolean } = {}): void {
if (!agent || agent !== chatState.agent || !chatState.host || appState.currentView !== "chats") return;
- const messages = visibleMessages(agent);
+ const currentMessages = visibleMessages(agent);
+ const messages = chatState.inheritedExpanded
+ ? [...chatState.inheritedMessages, ...currentMessages]
+ : currentMessages;
const isNewUser = sessionsState.list.filter((s) => s.id).length === 0;
let messageContent: Array | TemplateResult | typeof nothing = nothing;
+ const inheritedOffset = chatState.inheritedExpanded ? chatState.inheritedMessages.length : 0;
if (messages.length) {
messageContent = messages.map((m, i) =>
- settledChatMessage(m, i, agent.state.isStreaming && m === agent.state.streamingMessage),
+ settledChatMessage(m, i - inheritedOffset, agent.state.isStreaming && m === agent.state.streamingMessage),
);
} else if (isNewUser) {
messageContent = welcomeGreeting();
@@ -880,8 +1029,9 @@ export function createChatSurface(ctx: ConvCtx): ChatSurface {
glanceTier
? paneGlance(agent, messages, glanceTier)
: html`