mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Use derived signals for chat composer state
This commit is contained in:
parent
d6214bbdc1
commit
4d4aae59db
11 changed files with 146 additions and 46 deletions
|
|
@ -49,7 +49,7 @@ const removedChatStateEscapeHatchRestrictions = [
|
|||
const chatSignalAdapterRestrictions = [
|
||||
{
|
||||
selector: "ImportDeclaration[source.value='@preact/signals']",
|
||||
message: "Keep chat signals in panel/shell-state.tsx as a reducer-to-Preact notification adapter.",
|
||||
message: "Keep chat signals in panel/shell-state.tsx as a reducer-to-Preact notification and derived projection adapter.",
|
||||
},
|
||||
];
|
||||
const chatDomainLayerRestrictions = [
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ interface RuntimeSnapshotInput {
|
|||
activeThread: Pick<ChatState["activeThread"], "id" | "tokenUsage">;
|
||||
runtime: ChatState["runtime"];
|
||||
rateLimit: ChatState["connection"]["rateLimit"];
|
||||
items: readonly MessageStreamItem[];
|
||||
hasThreadTurns: boolean;
|
||||
availableModels: ChatState["connection"]["availableModels"];
|
||||
}
|
||||
|
||||
export function messageItemsHaveThreadTurns(items: readonly MessageStreamItem[]): boolean {
|
||||
return items.some((item) => item.turnId);
|
||||
}
|
||||
|
||||
export function runtimeSnapshotForChatSlices(input: RuntimeSnapshotInput): RuntimeSnapshot {
|
||||
return {
|
||||
runtimeConfig: input.runtimeConfig,
|
||||
|
|
@ -30,7 +34,7 @@ export function runtimeSnapshotForChatSlices(input: RuntimeSnapshotInput): Runti
|
|||
requestedFastMode: input.runtime.requestedFastMode,
|
||||
tokenUsage: input.activeThread.tokenUsage,
|
||||
rateLimit: input.rateLimit,
|
||||
hasThreadTurns: input.items.some((item) => item.turnId),
|
||||
hasThreadTurns: input.hasThreadTurns,
|
||||
availableModels: input.availableModels,
|
||||
};
|
||||
}
|
||||
|
|
@ -41,7 +45,7 @@ export function runtimeSnapshotForChatState(state: ChatState): RuntimeSnapshot {
|
|||
activeThread: state.activeThread,
|
||||
runtime: state.runtime,
|
||||
rateLimit: state.connection.rateLimit,
|
||||
items: messageStreamItems(state.messageStream),
|
||||
hasThreadTurns: messageItemsHaveThreadTurns(messageStreamItems(state.messageStream)),
|
||||
availableModels: state.connection.availableModels,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type { ComposerSubmitActions } from "../application/conversation/composer
|
|||
import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions";
|
||||
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
|
||||
import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
|
||||
import { activeTurnId, chatTurnBusy, type ChatAction, type ChatConnectionPhase } from "../application/state/root-reducer";
|
||||
import { chatTurnBusy, type ChatAction, type ChatConnectionPhase } from "../application/state/root-reducer";
|
||||
import { messageStreamItems } from "../application/state/message-stream";
|
||||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../application/lifecycle";
|
||||
|
|
@ -694,7 +694,7 @@ function createSessionComposerController(
|
|||
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut,
|
||||
scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges,
|
||||
canInterrupt: (state) => {
|
||||
return state.turn.lifecycle.kind !== "idle" && Boolean(state.activeThread.id && activeTurnId(state));
|
||||
return state.turnBusy && Boolean(state.activeThread.id && state.activeTurnId);
|
||||
},
|
||||
composerProjection: (state) => chatPanelComposerProjection(composerSurface, state),
|
||||
currentModelForSuggestions: () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { CodexInput } from "../../../domain/chat/input";
|
||||
import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard";
|
||||
import { textareaCursorAtVisualBoundary } from "../../../shared/ui/textarea-caret";
|
||||
import { chatTurnBusy, type ChatAction, type ChatState } from "../application/state/root-reducer";
|
||||
import type { ChatAction, ChatState } from "../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { ComposerShellProps } from "../ui/composer";
|
||||
import { syncComposerHeight, type ComposerCallbacks } from "../ui/composer";
|
||||
|
|
@ -64,7 +64,7 @@ export class ChatComposerController {
|
|||
return {
|
||||
viewId: this.options.viewId,
|
||||
draft: state.composer.draft,
|
||||
busy: chatTurnBusy(state),
|
||||
busy: state.turnBusy,
|
||||
canInterrupt: this.options.canInterrupt(state),
|
||||
normalPlaceholder: projection.placeholder,
|
||||
suggestions: state.composer.suggestions,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { createContext } from "preact";
|
||||
import { useContext } from "preact/hooks";
|
||||
import { batch, signal, type Signal } from "@preact/signals";
|
||||
import { batch, computed, signal, type ReadonlySignal, type Signal } from "@preact/signals";
|
||||
|
||||
import type { ChatState } from "../application/state/root-reducer";
|
||||
import type { RuntimeSnapshot } from "../domain/runtime/snapshot";
|
||||
import { messageItemsHaveThreadTurns, runtimeSnapshotForChatSlices } from "../application/runtime/snapshot";
|
||||
import { activeTurnId, chatTurnBusy, type ChatState } from "../application/state/root-reducer";
|
||||
import { messageStreamItems } from "../application/state/message-stream";
|
||||
|
||||
export interface ChatPanelShellState {
|
||||
connection: Signal<ChatState["connection"]>;
|
||||
|
|
@ -14,6 +17,10 @@ export interface ChatPanelShellState {
|
|||
requests: Signal<ChatState["requests"]>;
|
||||
composer: Signal<ChatState["composer"]>;
|
||||
ui: Signal<ChatState["ui"]>;
|
||||
turnBusy: ReadonlySignal<boolean>;
|
||||
activeTurnId: ReadonlySignal<string | null>;
|
||||
hasThreadTurns: ReadonlySignal<boolean>;
|
||||
composerRuntimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
|
||||
}
|
||||
|
||||
export type ChatPanelToolbarShellState = Pick<ChatState, "connection" | "threadList" | "activeThread" | "runtime" | "turn" | "ui">;
|
||||
|
|
@ -22,24 +29,51 @@ export type ChatPanelGoalShellState = Pick<ChatState, "activeThread" | "ui">;
|
|||
|
||||
export type ChatPanelMessageStreamShellState = Pick<ChatState, "activeThread" | "runtime" | "turn" | "messageStream" | "requests" | "ui">;
|
||||
|
||||
export type ChatPanelComposerShellState = Pick<
|
||||
export interface ChatPanelComposerShellState extends Pick<
|
||||
ChatState,
|
||||
"connection" | "threadList" | "activeThread" | "runtime" | "turn" | "messageStream" | "composer"
|
||||
>;
|
||||
"connection" | "threadList" | "activeThread" | "runtime" | "composer"
|
||||
> {
|
||||
readonly turnBusy: boolean;
|
||||
readonly activeTurnId: string | null;
|
||||
readonly runtimeSnapshot: RuntimeSnapshot;
|
||||
}
|
||||
|
||||
export const ChatPanelShellStateContext = createContext<ChatPanelShellState | null>(null);
|
||||
|
||||
export function createChatPanelShellState(initialState: ChatState): ChatPanelShellState {
|
||||
const connection = signal(initialState.connection);
|
||||
const threadList = signal(initialState.threadList);
|
||||
const activeThread = signal(initialState.activeThread);
|
||||
const runtime = signal(initialState.runtime);
|
||||
const turn = signal(initialState.turn);
|
||||
const messageStream = signal(initialState.messageStream);
|
||||
const requests = signal(initialState.requests);
|
||||
const composer = signal(initialState.composer);
|
||||
const ui = signal(initialState.ui);
|
||||
const hasThreadTurns = computed(() => messageItemsHaveThreadTurns(messageStreamItems(messageStream.value)));
|
||||
return {
|
||||
connection: signal(initialState.connection),
|
||||
threadList: signal(initialState.threadList),
|
||||
activeThread: signal(initialState.activeThread),
|
||||
runtime: signal(initialState.runtime),
|
||||
turn: signal(initialState.turn),
|
||||
messageStream: signal(initialState.messageStream),
|
||||
requests: signal(initialState.requests),
|
||||
composer: signal(initialState.composer),
|
||||
ui: signal(initialState.ui),
|
||||
connection,
|
||||
threadList,
|
||||
activeThread,
|
||||
runtime,
|
||||
turn,
|
||||
messageStream,
|
||||
requests,
|
||||
composer,
|
||||
ui,
|
||||
turnBusy: computed(() => chatTurnBusy({ turn: turn.value })),
|
||||
activeTurnId: computed(() => activeTurnId({ turn: turn.value })),
|
||||
hasThreadTurns,
|
||||
composerRuntimeSnapshot: computed(() =>
|
||||
runtimeSnapshotForChatSlices({
|
||||
runtimeConfig: connection.value.runtimeConfig,
|
||||
activeThread: activeThread.value,
|
||||
runtime: runtime.value,
|
||||
rateLimit: connection.value.rateLimit,
|
||||
hasThreadTurns: hasThreadTurns.value,
|
||||
availableModels: connection.value.availableModels,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -92,9 +126,10 @@ export function composerStateFromShellState(shellState: ChatPanelShellState): Ch
|
|||
threadList: shellState.threadList.value,
|
||||
activeThread: shellState.activeThread.value,
|
||||
runtime: shellState.runtime.value,
|
||||
turn: shellState.turn.value,
|
||||
messageStream: shellState.messageStream.value,
|
||||
composer: shellState.composer.value,
|
||||
turnBusy: shellState.turnBusy.value,
|
||||
activeTurnId: shellState.activeTurnId.value,
|
||||
runtimeSnapshot: shellState.composerRuntimeSnapshot.value,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
|
|||
import { ComposerShell, type ComposerShellProps } from "../../ui/composer";
|
||||
import { composerStateFromShellState, useChatPanelShellState, type ChatPanelComposerShellState } from "../shell-state";
|
||||
import { explicitThreadName } from "../../../../domain/threads/model";
|
||||
import { runtimeSnapshotForShellState } from "./runtime-snapshot";
|
||||
|
||||
interface RestoredThreadTitleSnapshot {
|
||||
threadId: string;
|
||||
|
|
@ -105,7 +104,7 @@ export function chatPanelComposerProjection(
|
|||
surface: ChatPanelComposerSurface,
|
||||
state: ChatPanelComposerShellState,
|
||||
): ChatPanelComposerProjection {
|
||||
const snapshot = runtimeSnapshotForShellState(state);
|
||||
const snapshot = state.runtimeSnapshot;
|
||||
return {
|
||||
placeholder: composerPlaceholder(activeComposerThreadName(state, surface.thread.restoredPlaceholder())),
|
||||
meta: {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
import { messageStreamItems } from "../../application/state/message-stream";
|
||||
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
|
||||
import { runtimeSnapshotForChatSlices } from "../../application/runtime/snapshot";
|
||||
import type { ChatPanelComposerShellState, ChatPanelToolbarShellState } from "../shell-state";
|
||||
|
||||
export function runtimeSnapshotForShellState(state: ChatPanelComposerShellState): RuntimeSnapshot {
|
||||
return runtimeSnapshotForSurfaceState(state, messageStreamItems(state.messageStream));
|
||||
}
|
||||
import type { ChatPanelToolbarShellState } from "../shell-state";
|
||||
|
||||
export function runtimeSnapshotForToolbarShellState(state: ChatPanelToolbarShellState): RuntimeSnapshot {
|
||||
// Toolbar shell state intentionally avoids subscribing to messageStream.
|
||||
return runtimeSnapshotForSurfaceState(state, []);
|
||||
return runtimeSnapshotForSurfaceState(state, false);
|
||||
}
|
||||
|
||||
function runtimeSnapshotForSurfaceState(
|
||||
|
|
@ -18,14 +13,14 @@ function runtimeSnapshotForSurfaceState(
|
|||
readonly activeThread: ChatPanelToolbarShellState["activeThread"];
|
||||
readonly runtime: ChatPanelToolbarShellState["runtime"];
|
||||
},
|
||||
items: Parameters<typeof runtimeSnapshotForChatSlices>[0]["items"],
|
||||
hasThreadTurns: boolean,
|
||||
): RuntimeSnapshot {
|
||||
return runtimeSnapshotForChatSlices({
|
||||
runtimeConfig: state.connection.runtimeConfig,
|
||||
activeThread: state.activeThread,
|
||||
runtime: state.runtime,
|
||||
rateLimit: state.connection.rateLimit,
|
||||
items,
|
||||
hasThreadTurns,
|
||||
availableModels: state.connection.availableModels,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { SkillMetadata } from "../../../../../src/domain/catalog/metadata";
|
|||
import { installObsidianDomShims } from "../../../../support/dom";
|
||||
import type { ChatPanelComposerShellState } from "../../../../../src/features/chat/panel/shell-state";
|
||||
import type { NoteCandidateProvider } from "../../../../../src/features/chat/application/composer/note-context";
|
||||
import { composerShellStateFromChatState } from "../../support/shell-state";
|
||||
|
||||
installObsidianDomShims();
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ function renderComposerController(
|
|||
stateStore: ChatStateStore,
|
||||
actions: ChatComposerRenderActions = { submit: vi.fn() },
|
||||
): void {
|
||||
renderUiRoot(parent, h(ComposerShell, controller.renderState(stateStore.getState(), actions)));
|
||||
renderUiRoot(parent, h(ComposerShell, controller.renderState(composerShellStateFromChatState(stateStore.getState()), actions)));
|
||||
}
|
||||
|
||||
describe("ChatComposerController", () => {
|
||||
|
|
@ -49,7 +50,7 @@ describe("ChatComposerController", () => {
|
|||
onHeightChange: vi.fn(),
|
||||
});
|
||||
|
||||
const props = controller.renderState(stateStore.getState(), { submit: vi.fn() });
|
||||
const props = controller.renderState(composerShellStateFromChatState(stateStore.getState()), { submit: vi.fn() });
|
||||
|
||||
expect(props.normalPlaceholder).toBe("Projected empty");
|
||||
expect(props.meta.statusSummary).toBe(
|
||||
|
|
|
|||
|
|
@ -68,6 +68,51 @@ describe("ChatPanelShell", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps composer rendering off message stream updates until turn presence changes", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const parts = shellParts();
|
||||
const renderComposerState = vi.fn(parts.composer.renderer.renderState.bind(parts.composer.renderer));
|
||||
parts.composer.renderer.renderState = renderComposerState;
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, { ...shellProps(store), parts });
|
||||
await settleShellEffects();
|
||||
});
|
||||
renderComposerState.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({
|
||||
type: "message-stream/system-item-added",
|
||||
item: { id: "system-1", kind: "system", role: "system", text: "Status only." },
|
||||
});
|
||||
await settleShellEffects();
|
||||
});
|
||||
expect(renderComposerState).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
store.dispatch({
|
||||
type: "message-stream/item-added",
|
||||
item: {
|
||||
id: "assistant-1",
|
||||
turnId: "turn-1",
|
||||
kind: "message",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
role: "assistant",
|
||||
text: "Turn response.",
|
||||
},
|
||||
});
|
||||
await settleShellEffects();
|
||||
});
|
||||
expect(renderComposerState).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("removes and restores the toolbar from shell props without replacing the body regions", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { ChatPanelShellStateContext, createChatPanelShellState } from "../../../
|
|||
import type { ToolbarActions } from "../../../../../src/features/chat/ui/toolbar";
|
||||
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
|
||||
import { installObsidianDomShims } from "../../../../support/dom";
|
||||
import { composerShellStateFromChatState } from "../../support/shell-state";
|
||||
|
||||
installObsidianDomShims();
|
||||
|
||||
|
|
@ -110,7 +111,7 @@ describe("chat panel surface projections", () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({
|
||||
expect(composerProjectionFromState(composerSurfaceFixture(), state).meta).toMatchObject({
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -145,7 +146,7 @@ describe("chat panel surface projections", () => {
|
|||
]);
|
||||
state = chatStateWith(state, { connection: { runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5" }) } });
|
||||
|
||||
expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({
|
||||
expect(composerProjectionFromState(composerSurfaceFixture(), state).meta).toMatchObject({
|
||||
fatal: null,
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -175,7 +176,7 @@ describe("chat panel surface projections", () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({
|
||||
expect(composerProjectionFromState(composerSurfaceFixture(), state).meta).toMatchObject({
|
||||
context: {
|
||||
cells: [
|
||||
{ text: "⣀", placeholder: true },
|
||||
|
|
@ -193,7 +194,7 @@ describe("chat panel surface projections", () => {
|
|||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { connection: { phase: { kind: "failed", message: "Connection failed." } } });
|
||||
|
||||
expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({
|
||||
expect(composerProjectionFromState(composerSurfaceFixture(), state).meta).toMatchObject({
|
||||
fatal: "Codex app-server disconnected",
|
||||
context: {
|
||||
cells: [
|
||||
|
|
@ -263,7 +264,7 @@ describe("chat panel surface projections", () => {
|
|||
const selectedModels: string[] = [];
|
||||
const selectedEfforts: string[] = [];
|
||||
|
||||
const choices = chatPanelComposerProjection(
|
||||
const choices = composerProjectionFromState(
|
||||
composerSurfaceFixture({
|
||||
runtime: {
|
||||
requestModel: async (model) => {
|
||||
|
|
@ -344,7 +345,7 @@ describe("chat panel surface projections", () => {
|
|||
});
|
||||
state = chatStateWith(state, { connection: { availableModels: [modelFixture("gpt-5.5")] } });
|
||||
|
||||
const projection = chatPanelComposerProjection(composerSurfaceFixture(), state);
|
||||
const projection = composerProjectionFromState(composerSurfaceFixture(), state);
|
||||
|
||||
expect(projection).toMatchObject({
|
||||
placeholder: "Ask Codex to work on this task...",
|
||||
|
|
@ -362,8 +363,8 @@ describe("chat panel surface projections", () => {
|
|||
activeState = chatStateWith(activeState, { activeThread: { id: "thread-1" } });
|
||||
activeState = chatStateWith(activeState, { threadList: { listedThreads: [threadFixture("thread-1", "Active")] } });
|
||||
|
||||
expect(chatPanelComposerProjection(composerSurfaceFixture(), activeState).placeholder).toBe("Ask Codex to work on “Active”...");
|
||||
expect(chatPanelComposerProjection(composerSurfaceFixture(), chatStateFixture()).placeholder).toBe("Ask Codex to work on this task...");
|
||||
expect(composerProjectionFromState(composerSurfaceFixture(), activeState).placeholder).toBe("Ask Codex to work on “Active”...");
|
||||
expect(composerProjectionFromState(composerSurfaceFixture(), chatStateFixture()).placeholder).toBe("Ask Codex to work on this task...");
|
||||
});
|
||||
|
||||
it("uses restored thread names in the composer projection", () => {
|
||||
|
|
@ -372,7 +373,7 @@ describe("chat panel surface projections", () => {
|
|||
state = chatStateWith(state, { threadList: { listedThreads: [threadFixture("thread-1", null)] } });
|
||||
|
||||
expect(
|
||||
chatPanelComposerProjection(
|
||||
composerProjectionFromState(
|
||||
composerSurfaceFixture({
|
||||
thread: {
|
||||
restoredPlaceholder: () => ({ threadId: "thread-1", title: "Restored", explicitName: "Restored" }),
|
||||
|
|
@ -404,6 +405,10 @@ function renderWithShellState(state: ChatState, node: ComponentChild): HTMLEleme
|
|||
return parent;
|
||||
}
|
||||
|
||||
function composerProjectionFromState(surface: ChatPanelComposerSurface, state: ChatState) {
|
||||
return chatPanelComposerProjection(surface, composerShellStateFromChatState(state));
|
||||
}
|
||||
|
||||
function clickLabeledButton(parent: HTMLElement, label: string): void {
|
||||
const button = Array.from(parent.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.getAttribute("aria-label") === label);
|
||||
if (!button) throw new Error(`Expected button: ${label}`);
|
||||
|
|
|
|||
16
tests/features/chat/support/shell-state.ts
Normal file
16
tests/features/chat/support/shell-state.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { runtimeSnapshotForChatState } from "../../../../src/features/chat/application/runtime/snapshot";
|
||||
import { activeTurnId, chatTurnBusy, type ChatState } from "../../../../src/features/chat/application/state/root-reducer";
|
||||
import type { ChatPanelComposerShellState } from "../../../../src/features/chat/panel/shell-state";
|
||||
|
||||
export function composerShellStateFromChatState(state: ChatState): ChatPanelComposerShellState {
|
||||
return {
|
||||
connection: state.connection,
|
||||
threadList: state.threadList,
|
||||
activeThread: state.activeThread,
|
||||
runtime: state.runtime,
|
||||
composer: state.composer,
|
||||
turnBusy: chatTurnBusy(state),
|
||||
activeTurnId: activeTurnId(state),
|
||||
runtimeSnapshot: runtimeSnapshotForChatState(state),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue