fix(chat): recall the current session's prompts by default; tidy the six merged PRs
Input history (#3035) shipped with "All projects" as the default scope and only recorded prompts sent after the upgrade, so ArrowUp showed other sessions' prompts and, once switched to "Current session", nothing at all. Default to the current session and merge the visible transcript's prompts with the persisted bucket. Existing sessions recall as they did before #3035, while new prompts keep their attachments and stay recallable after a revert hides them from the transcript. Cleanup across #1855, #2297, #3072, #3178, #3035 and #3135: drop the duplicate poll guards in the file content poller, the zod schema the VS Code package cannot depend on, a copied file-URL helper and stray whitespace; move the Enter-to-send strings into the settings namespace; document OPENCHAMBER_CHATS_DIR, resolve the chats root once on the server and warm it alongside the other bootstrap calls.
This commit is contained in:
@@ -198,7 +198,7 @@ Rules:
|
||||
7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree.
|
||||
8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
|
||||
9. Transcript arrays are chronological by `message.time.created`, with message ID used only as a deterministic equal-time tie-breaker. Message IDs are identity and reconciliation keys, not chronology: OpenCode's fixed-width sortable timestamp prefix rolls over, so a newer `msg_000...` can follow an older `msg_fff...`. Fetch, pagination, materialization, optimistic insertion, events, reconnect inspection, rendering, and revert/undo/redo must preserve this contract.
|
||||
10. Transcript visibility and revert markers do not own prompt recall. The sync layer may hide reverted user messages from the visible transcript, but ArrowUp and ArrowDown recall come from the persisted input-history store, scoped by runtime and by runtime + normalized directory + session identity.
|
||||
10. Session-scoped ArrowUp and ArrowDown recall merges the visible transcript's user prompts (`useUserMessageHistory`) with the persisted input-history bucket for runtime + normalized directory + session identity. Revert markers hide prompts from the transcript source only; the persisted bucket still recalls them. Global scope reads the persisted runtime bucket alone.
|
||||
11. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted.
|
||||
|
||||
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering.
|
||||
|
||||
@@ -69,9 +69,10 @@ export async function bootstrapGlobal(
|
||||
sdk: OpencodeClient,
|
||||
set: (patch: Partial<GlobalState>) => void,
|
||||
) {
|
||||
// Sync chat classification needs the root before session lists load.
|
||||
await warmChatsRootDirectory()
|
||||
const results = await Promise.allSettled([
|
||||
// Sync chat classification needs the chats root before session lists load;
|
||||
// it resolves alongside the other bootstrap calls, not ahead of them.
|
||||
warmChatsRootDirectory(),
|
||||
retry(() => sdk.path.get().then((x) => set({ path: unwrap(x, "path.get") }))),
|
||||
retry(() => sdk.global.config.get().then((x) => set({ config: unwrap(x, "global.config.get") }))),
|
||||
retry(() =>
|
||||
|
||||
@@ -36,6 +36,7 @@ export type SyncPerformanceCounters = {
|
||||
questionChangeCallbacks: number
|
||||
sessionMessageChangeCallbacks: number
|
||||
sessionRenderableNotificationSkips: number
|
||||
userMessageHistoryNotificationSkips: number
|
||||
sessionMessageRecordNotificationSkips: number
|
||||
materializationEnqueues: number
|
||||
materializationEmptyAssistantEnqueues: number
|
||||
@@ -78,6 +79,7 @@ const createCounters = (): SyncPerformanceCounters => ({
|
||||
questionChangeCallbacks: 0,
|
||||
sessionMessageChangeCallbacks: 0,
|
||||
sessionRenderableNotificationSkips: 0,
|
||||
userMessageHistoryNotificationSkips: 0,
|
||||
sessionMessageRecordNotificationSkips: 0,
|
||||
materializationEnqueues: 0,
|
||||
materializationEmptyAssistantEnqueues: 0,
|
||||
|
||||
@@ -99,14 +99,6 @@ import {
|
||||
|
||||
export type { AttachedFile }
|
||||
|
||||
function appendInputHistorySubmissions(
|
||||
identity: ReturnType<typeof createInputHistoryIdentity>,
|
||||
submissions: readonly InputHistorySubmission[],
|
||||
): void {
|
||||
if (!identity || submissions.length === 0) return
|
||||
useInputHistoryStore.getState().appendSubmissions(identity, submissions)
|
||||
}
|
||||
|
||||
type GoalCommand = { name: string; template?: string }
|
||||
|
||||
export function expandSlashCommandGoalObjective(content: string, commands: GoalCommand[]): string {
|
||||
@@ -1734,8 +1726,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
createdDraftSession.directory ?? '',
|
||||
createdDraftSession.sessionId,
|
||||
)
|
||||
const appendSubmissions = historyIdentity && options?.historySubmissions?.length
|
||||
? () => appendInputHistorySubmissions(historyIdentity, options.historySubmissions ?? [])
|
||||
const historySubmissions = options?.historySubmissions
|
||||
const appendSubmissions = historyIdentity && historySubmissions?.length
|
||||
? () => useInputHistoryStore.getState().appendSubmissions(historyIdentity, historySubmissions)
|
||||
: undefined
|
||||
|
||||
notifyMessageSent(createdDraftSession.sessionId)
|
||||
@@ -1860,13 +1853,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
const partsWithPinnedContext = prefixParts.length > 0
|
||||
? [...prefixParts, ...(additionalParts || [])]
|
||||
: additionalParts
|
||||
const currentHistoryIdentity = createInputHistoryIdentity(
|
||||
const historyIdentity = createInputHistoryIdentity(
|
||||
capturedRuntimeKey,
|
||||
currentSessionDirectory ?? '',
|
||||
targetSessionId || '',
|
||||
)
|
||||
const appendSubmissions = currentHistoryIdentity && options?.historySubmissions?.length
|
||||
? () => appendInputHistorySubmissions(currentHistoryIdentity, options.historySubmissions ?? [])
|
||||
const historySubmissions = options?.historySubmissions
|
||||
const appendSubmissions = historyIdentity && historySubmissions?.length
|
||||
? () => useInputHistoryStore.getState().appendSubmissions(historyIdentity, historySubmissions)
|
||||
: undefined
|
||||
|
||||
const messageRoute = await routeMessage({
|
||||
|
||||
@@ -86,6 +86,7 @@ import { formatMessage, useI18nStore } from "@/lib/i18n"
|
||||
import { sessionEvents } from "@/lib/sessionEvents"
|
||||
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
||||
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
|
||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type TranscriptPrompt, type UserMessageHistorySnapshot } from "./user-message-history"
|
||||
import {
|
||||
EMPTY_SESSION_MESSAGE_LOAD_STATE,
|
||||
SessionMessageLoader,
|
||||
@@ -3432,6 +3433,51 @@ export function useSessionRenderable(sessionID: string, directory?: string): boo
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's prompts in the visible transcript of a session, oldest first.
|
||||
* Session-scoped ArrowUp recall merges this with the persisted input history,
|
||||
* so sessions that predate the persisted store still recall their prompts.
|
||||
*/
|
||||
export function useUserMessageHistory(sessionID: string, directory?: string): TranscriptPrompt[] {
|
||||
const store = useDirectoryStore(directory)
|
||||
const snapshotRef = useRef<UserMessageHistorySnapshot>(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT)
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
const next = buildUserMessageHistorySnapshot(store.getState(), sessionID, snapshotRef.current)
|
||||
snapshotRef.current = next
|
||||
return next.history
|
||||
}, [sessionID, store])
|
||||
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!sessionID) return () => undefined
|
||||
const unsubscribeMessages = subscribeDirectorySessionMessages(store, sessionID, (change) => {
|
||||
if (!change.messagesChanged && !change.reset && change.partMessageIDs.length > 0) {
|
||||
const records = snapshotRef.current.sessionID === sessionID ? snapshotRef.current.records : []
|
||||
const affectsUserHistory = change.partMessageIDs.some((messageID) => (
|
||||
records.some((record) => record.message.id === messageID)
|
||||
))
|
||||
if (!affectsUserHistory) {
|
||||
countSyncPerformance("userMessageHistoryNotificationSkips")
|
||||
return
|
||||
}
|
||||
}
|
||||
notify()
|
||||
})
|
||||
const unsubscribeSession = store.subscribe((state, previous) => {
|
||||
if (state.session === previous.session) return
|
||||
const currentRevert = state.session.find((session) => session.id === sessionID)?.revert?.messageID
|
||||
const previousRevert = previous.session.find((session) => session.id === sessionID)?.revert?.messageID
|
||||
if (currentRevert !== previousRevert) notify()
|
||||
})
|
||||
return () => {
|
||||
unsubscribeMessages()
|
||||
unsubscribeSession()
|
||||
}
|
||||
}, [sessionID, store])
|
||||
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages for a session in the old {info, parts}[] format.
|
||||
* Uses visible messages (filtered by revert state).
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
import type { State } from './types';
|
||||
|
||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot } from './user-message-history';
|
||||
|
||||
const message = (id: string, role: 'user' | 'assistant', created = 1): Message => ({
|
||||
id,
|
||||
role,
|
||||
sessionID: 'ses_1',
|
||||
time: { created },
|
||||
} as Message);
|
||||
|
||||
const textPart = (id: string, text: string): Part => ({
|
||||
id,
|
||||
type: 'text',
|
||||
text,
|
||||
} as Part);
|
||||
|
||||
const state = (partial: Partial<State>): Pick<State, 'session' | 'message' | 'part'> => ({
|
||||
session: [],
|
||||
message: {},
|
||||
part: {},
|
||||
...partial,
|
||||
});
|
||||
|
||||
describe('buildUserMessageHistorySnapshot', () => {
|
||||
test('returns a shared empty snapshot without a session id', () => {
|
||||
expect(buildUserMessageHistorySnapshot(state({}), '')).toBe(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT);
|
||||
});
|
||||
|
||||
test('lists user prompts oldest first with their creation time', () => {
|
||||
const first = message('user_1', 'user', 10);
|
||||
const second = message('user_2', 'user', 20);
|
||||
const snapshot = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
message: { ses_1: [first, message('assistant_1', 'assistant', 15), second] },
|
||||
part: { user_1: [textPart('p1', 'first')], user_2: [textPart('p2', 'second')] },
|
||||
}),
|
||||
'ses_1',
|
||||
);
|
||||
|
||||
expect(snapshot.history).toEqual([
|
||||
{ text: 'first', createdAt: 10 },
|
||||
{ text: 'second', createdAt: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps history stable when assistant parts change', () => {
|
||||
const user = message('user_1', 'user');
|
||||
const assistant = message('assistant_1', 'assistant');
|
||||
const userParts = [textPart('part_user', 'hello')];
|
||||
const first = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
message: { ses_1: [user, assistant] },
|
||||
part: { user_1: userParts, assistant_1: [textPart('part_a', 'stream')] },
|
||||
}),
|
||||
'ses_1',
|
||||
);
|
||||
|
||||
const second = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
message: { ses_1: [user, assistant] },
|
||||
part: { user_1: userParts, assistant_1: [textPart('part_a2', 'streaming')] },
|
||||
}),
|
||||
'ses_1',
|
||||
first,
|
||||
);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(second.history.map((prompt) => prompt.text)).toEqual(['hello']);
|
||||
});
|
||||
|
||||
test('updates history when a user part changes', () => {
|
||||
const user = message('user_1', 'user');
|
||||
const first = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
message: { ses_1: [user] },
|
||||
part: { user_1: [textPart('part_user', 'hello')] },
|
||||
}),
|
||||
'ses_1',
|
||||
);
|
||||
|
||||
const second = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
message: { ses_1: [user] },
|
||||
part: { user_1: [textPart('part_user_updated', 'updated')] },
|
||||
}),
|
||||
'ses_1',
|
||||
first,
|
||||
);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.history.map((prompt) => prompt.text)).toEqual(['updated']);
|
||||
});
|
||||
|
||||
test('excludes user messages hidden by session revert state', () => {
|
||||
const beforeRevert = message('msg_ffffffffffffBefore', 'user');
|
||||
const reverted = message('msg_000000000000Reverted', 'user');
|
||||
|
||||
const snapshot = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
session: [{ id: 'ses_1', revert: { messageID: reverted.id } } as State['session'][number]],
|
||||
message: { ses_1: [beforeRevert, reverted] },
|
||||
part: {
|
||||
[beforeRevert.id]: [textPart('part_user_1', 'kept')],
|
||||
[reverted.id]: [textPart('part_user_2', 'reverted')],
|
||||
},
|
||||
}),
|
||||
'ses_1',
|
||||
);
|
||||
|
||||
expect(snapshot.history.map((prompt) => prompt.text)).toEqual(['kept']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
import type { State } from './types';
|
||||
import { messagesBefore } from './message-ordering';
|
||||
|
||||
type UserMessageHistoryRecord = {
|
||||
message: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
|
||||
/** One prompt the user sent in a session, as the visible transcript shows it. */
|
||||
export type TranscriptPrompt = {
|
||||
text: string;
|
||||
/** `message.time.created`, milliseconds. */
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type UserMessageHistorySnapshot = {
|
||||
sessionID: string;
|
||||
revertMessageID?: string;
|
||||
records: UserMessageHistoryRecord[];
|
||||
/** Oldest first. */
|
||||
history: TranscriptPrompt[];
|
||||
};
|
||||
|
||||
const EMPTY_PARTS: Part[] = [];
|
||||
const EMPTY_RECORDS: UserMessageHistoryRecord[] = [];
|
||||
const EMPTY_HISTORY: TranscriptPrompt[] = [];
|
||||
|
||||
export const EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT: UserMessageHistorySnapshot = {
|
||||
sessionID: '',
|
||||
revertMessageID: undefined,
|
||||
records: EMPTY_RECORDS,
|
||||
history: EMPTY_HISTORY,
|
||||
};
|
||||
|
||||
const getFirstTextFromParts = (parts: Part[]): string => {
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text' && part.text.length > 0) return part.text;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const areRecordsEqual = (left: UserMessageHistoryRecord[], right: UserMessageHistoryRecord[]): boolean => {
|
||||
if (left === right) return true;
|
||||
if (left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index]?.message !== right[index]?.message || left[index]?.parts !== right[index]?.parts) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's prompts in a session, oldest first, limited to what the visible
|
||||
* transcript shows: messages at or after a revert marker are excluded. Returns
|
||||
* the previous snapshot when nothing relevant changed so subscribers can skip
|
||||
* work on assistant-only updates.
|
||||
*/
|
||||
export const buildUserMessageHistorySnapshot = (
|
||||
state: Pick<State, 'session' | 'message' | 'part'>,
|
||||
sessionID: string,
|
||||
previous: UserMessageHistorySnapshot = EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT,
|
||||
): UserMessageHistorySnapshot => {
|
||||
if (!sessionID) {
|
||||
return EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT;
|
||||
}
|
||||
|
||||
const messages = state.message[sessionID] ?? [];
|
||||
const session = state.session.find((candidate) => candidate.id === sessionID);
|
||||
const revertMessageID = session?.revert?.messageID;
|
||||
const records: UserMessageHistoryRecord[] = [];
|
||||
for (const message of messagesBefore(messages, revertMessageID)) {
|
||||
if (message.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
records.push({
|
||||
message,
|
||||
parts: state.part[message.id] ?? EMPTY_PARTS,
|
||||
});
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && previous.records.length === 0
|
||||
? previous
|
||||
: { sessionID, revertMessageID, records: EMPTY_RECORDS, history: EMPTY_HISTORY };
|
||||
}
|
||||
|
||||
if (previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && areRecordsEqual(previous.records, records)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const history: TranscriptPrompt[] = [];
|
||||
for (const record of records) {
|
||||
const text = getFirstTextFromParts(record.parts);
|
||||
if (text.length > 0) {
|
||||
history.push({ text, createdAt: record.message.time.created });
|
||||
}
|
||||
}
|
||||
|
||||
return { sessionID, revertMessageID, records, history };
|
||||
};
|
||||
Reference in New Issue
Block a user