Extract turn diff view feature

This commit is contained in:
murashit 2026-06-27 21:49:26 +09:00
parent 727f5393af
commit ca53d77fed
18 changed files with 171 additions and 166 deletions

View file

@ -155,6 +155,7 @@
"!**/src/features/chat/panel/**",
"!**/src/features/chat/ui/**",
"!**/src/features/selection-rewrite/*.dom.tsx",
"!**/src/features/turn-diff/*.dom.tsx",
"!**/src/features/threads-view/*.dom.tsx",
"!**/src/settings/**",
"!**/src/shared/ui/**"

View file

@ -38,6 +38,7 @@ The source tree is organized by implementation ownership, not by the single Obsi
- `src/main.ts` and `src/plugin-runtime.ts` own Obsidian plugin registration, lifecycle wiring, and cross-surface runtime coordination.
- `src/app-server/` owns the app-server boundary through responsibility subfolders such as `connection/`, `protocol/`, `query/`, `routing/`, and `services/`. Do not add root-level app-server modules.
- `src/features/` contains feature-owned surfaces and workflows. Feature-to-feature imports are acceptable only when the imported feature owns a concrete capability.
- `src/features/turn-diff/` owns the turn diff Obsidian view, renderer, and persisted view state; chat provides current turn diff state through its workspace contract.
- `src/workspace/` and `src/settings/` own Obsidian workspace coordination and settings-tab behavior.
- `src/domain/` contains panel-owned, generated-independent meaning models and pure derivations. Domain code must not import app-server protocol, generated bindings, feature modules, UI, or Obsidian APIs.
- `src/shared/` contains feature-neutral helpers. Move behavior here only when it is genuinely shared outside one feature.
@ -45,13 +46,13 @@ The source tree is organized by implementation ownership, not by the single Obsi
Within `src/features/chat/`:
- `domain/` owns chat-local meaning models and pure derivations for message stream items, pending request display facts, runtime intent, local IDs, and turn diffs.
- `domain/` owns chat-local meaning models and pure derivations for message stream items, pending request display facts, runtime intent, and local IDs.
- `application/` owns chat state transitions and workflow orchestration for composer input, turn submission, thread lifecycle, pending requests, runtime settings, connection status, and reducer state.
- `app-server/` adapts app-server notifications, requests, thread payloads, diagnostics, and turn items into chat-owned actions, projections, and message stream models.
- `host/` wires each chat session to Obsidian views, workspace services, app-server clients, plugin settings, shared runtime state, lifecycle cleanup, and cross-surface handles.
- `panel/` owns the Preact shell, shell-state projection, panel controllers, toolbar/composer/message-stream surface adapters, and Obsidian-facing panel snapshots.
- `presentation/` owns view-model projection, grouping, formatting, and status text for chat UI surfaces.
- `ui/` owns Preact and explicit DOM bridge rendering pieces for the composer, toolbar, goal view, message stream, and turn diff view.
- `ui/` owns Preact and explicit DOM bridge rendering pieces for the composer, toolbar, goal view, and message stream.
Tests should mirror the ownership boundary of the code under test. Keep chat feature tests under `tests/features/chat/app-server/`, `application/`, `domain/`, `host/`, `panel/`, `presentation/`, or `ui/` instead of flattened shortcut folders such as `connection/`, `composer/`, `message-stream/`, `runtime/`, `threads/`, or `conversation/`. Feature-neutral tests belong under `tests/shared/`.

View file

@ -10,7 +10,7 @@ import type { SharedServerMetadata } from "../../../domain/server/metadata";
import type { ArchiveExportSettings } from "../../../domain/threads/archive-markdown";
import type { ObservedResultListener } from "../../../shared/query/observed-result";
import type { SendShortcut } from "../../../shared/ui/keyboard";
import type { ChatTurnDiffViewState } from "../domain/turn-diff";
import type { TurnDiffViewState } from "../../turn-diff/model";
export interface CodexChatHost {
readonly settingsRef: ChatPanelSettingsRef;
@ -38,7 +38,7 @@ export interface ChatPanelSettingsAccess {
interface WorkspacePanels {
openThreadInNewView(threadId: string): Promise<void>;
focusThreadInOpenView(threadId: string): Promise<boolean>;
openTurnDiff(state: ChatTurnDiffViewState): Promise<void>;
openTurnDiff(state: TurnDiffViewState): Promise<void>;
refreshThreadsViewLiveState(): void;
}

View file

@ -2,10 +2,10 @@ import type { App, Component } from "obsidian";
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import { copyTextWithNotice } from "../../../../shared/ui/clipboard";
import type { TurnDiffViewState } from "../../../turn-diff/model";
import type { PendingRequestBlockActions } from "../../application/pending-requests/block";
import type { ChatAction } from "../../application/state/root-reducer";
import type { ChatStateStore } from "../../application/state/store";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
import type { MessageStreamScrollControllerBinding } from "../../ui/message-stream/flow-scroll.measure";
import { MarkdownMessageRenderer, renderStreamMarkdown } from "../../ui/message-stream/markdown-renderer.obsidian";
import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/stream-blocks";
@ -32,7 +32,7 @@ interface ChatMessageStreamActions {
rollbackThread: (threadId: string) => void;
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
implementPlan: (itemId: string) => void;
openTurnDiff: (state: ChatTurnDiffViewState) => void;
openTurnDiff: (state: TurnDiffViewState) => void;
}
interface ChatMessageStreamRequests {

View file

@ -1,10 +1,10 @@
import type { TurnDiffViewState } from "../../../turn-diff/model";
import { type PendingRequestBlockActions, pendingRequestBlockStateFromChatState } from "../../application/pending-requests/block";
import type { MessageStreamRollbackCandidate } from "../../application/state/message-stream";
import type { ChatAction } from "../../application/state/root-reducer";
import type { ChatDisclosureBucket, ChatDisclosureUiState } from "../../application/state/ui-state";
import { type ForkCandidate, messageStreamSegmentsEmpty, type PlanImplementationTarget } from "../../domain/message-stream/selectors";
import { pendingRequestsSignature } from "../../domain/pending-requests/signatures";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
import type { MessageStreamTextActionTargets } from "../../presentation/message-stream/text-view";
import { type MessageStreamViewBlock, messageStreamViewBlocks } from "../../presentation/message-stream/view-model";
import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model";
@ -15,7 +15,7 @@ interface ChatMessageStreamActions {
rollbackThread: (threadId: string) => void;
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
implementPlan: (itemId: string) => void;
openTurnDiff: (state: ChatTurnDiffViewState) => void;
openTurnDiff: (state: TurnDiffViewState) => void;
}
interface ChatMessageStreamRequests {

View file

@ -1,6 +1,6 @@
import type { ApprovalAction, McpElicitationAction, PendingRequestId } from "../../../../domain/pending-requests/model";
import type { TurnDiffViewState } from "../../../turn-diff/model";
import type { PlanImplementationTarget } from "../../domain/message-stream/selectors";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
import type { MessageStreamForkTarget } from "../../presentation/message-stream/text-view";
import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/view-model";
@ -53,7 +53,7 @@ export interface TextItemActionContext extends TextItemDetailStateContext {
export interface TextItemMetadataContext extends TextItemDetailStateContext {
activeThreadId: string | null;
workspaceRoot?: string | null;
openTurnDiff?: (state: ChatTurnDiffViewState) => void;
openTurnDiff?: (state: TurnDiffViewState) => void;
}
interface MessageStreamRenderContext {

View file

@ -1,87 +0,0 @@
import type { ComponentChild as UiNode } from "preact";
import { shortThreadId } from "../../../../shared/id/thread-id";
import { IconButton } from "../../../../shared/ui/components.obsidian";
import { UnifiedDiffView } from "../../../../shared/ui/diff";
import { renderUiRoot } from "../../../../shared/ui/ui-root.dom";
import type { ChatTurnDiffViewState, PersistedChatTurnDiffViewState } from "../../domain/turn-diff";
export interface ChatTurnDiffViewActions {
copyDiff?: () => void;
}
export function renderChatTurnDiffView(
parent: HTMLElement,
state: ChatTurnDiffViewState | null,
actions: ChatTurnDiffViewActions = {},
metadata: PersistedChatTurnDiffViewState | null = null,
): void {
parent.addClass("codex-panel-chat-turn-diff");
renderUiRoot(parent, <ChatTurnDiffView state={state} actions={actions} metadata={metadata} />);
}
function ChatTurnDiffView({
state,
actions,
metadata,
}: {
state: ChatTurnDiffViewState | null;
actions: ChatTurnDiffViewActions;
metadata: PersistedChatTurnDiffViewState | null;
}): UiNode {
if (!state) {
if (metadata) {
return (
<>
<TurnDiffHeader state={metadata} copyDiff={null} />
<div className="codex-panel-chat-turn-diff__empty">Turn diff is no longer available.</div>
</>
);
}
return <div className="codex-panel-chat-turn-diff__empty">No turn diff selected.</div>;
}
return (
<>
<TurnDiffHeader state={state} copyDiff={actions.copyDiff ?? null} />
{state.files.length > 0 ? <ChangedFiles files={state.files} /> : null}
<UnifiedDiffView diff={state.diff} className="codex-panel-chat-turn-diff__diff" />
</>
);
}
function TurnDiffHeader({
state,
copyDiff,
}: {
state: PersistedChatTurnDiffViewState;
copyDiff: ChatTurnDiffViewActions["copyDiff"] | null;
}): UiNode {
return (
<div className="codex-panel-chat-turn-diff__header">
<div className="codex-panel-chat-turn-diff__title-block">
<div className="codex-panel-chat-turn-diff__title">Turn diff</div>
<div className="codex-panel-chat-turn-diff__meta">
{shortThreadId(state.threadId)} / {shortThreadId(state.turnId)} ·{" "}
{state.files.length === 1 ? "Edited 1 file" : `Edited ${String(state.files.length)} files`}
</div>
</div>
{copyDiff ? (
<IconButton icon="copy" label="Copy diff" className="clickable-icon codex-panel-chat-turn-diff__copy" onClick={copyDiff} />
) : null}
</div>
);
}
function ChangedFiles({ files }: { files: string[] }): UiNode {
return (
<details className="codex-panel-chat-turn-diff__files">
<summary tabIndex={-1}>Changed files</summary>
<ul>
{files.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
</details>
);
}

View file

@ -1,4 +1,4 @@
export interface ChatTurnDiffViewState {
export interface TurnDiffViewState {
threadId: string;
turnId: string;
cwd: string | null;
@ -6,9 +6,9 @@ export interface ChatTurnDiffViewState {
diff: string;
}
export type PersistedChatTurnDiffViewState = Omit<ChatTurnDiffViewState, "diff">;
export type PersistedTurnDiffViewState = Omit<TurnDiffViewState, "diff">;
export function persistedChatTurnDiffViewState(state: ChatTurnDiffViewState): PersistedChatTurnDiffViewState {
export function persistedTurnDiffViewState(state: TurnDiffViewState): PersistedTurnDiffViewState {
return {
threadId: state.threadId,
turnId: state.turnId,
@ -17,9 +17,9 @@ export function persistedChatTurnDiffViewState(state: ChatTurnDiffViewState): Pe
};
}
export function isPersistedChatTurnDiffViewState(value: unknown): value is PersistedChatTurnDiffViewState {
export function isPersistedTurnDiffViewState(value: unknown): value is PersistedTurnDiffViewState {
if (!value || typeof value !== "object") return false;
const record = value as Partial<PersistedChatTurnDiffViewState>;
const record = value as Partial<PersistedTurnDiffViewState>;
return (
typeof record.threadId === "string" &&
typeof record.turnId === "string" &&

View file

@ -0,0 +1,87 @@
import type { ComponentChild as UiNode } from "preact";
import { shortThreadId } from "../../shared/id/thread-id";
import { IconButton } from "../../shared/ui/components.obsidian";
import { UnifiedDiffView } from "../../shared/ui/diff";
import { renderUiRoot } from "../../shared/ui/ui-root.dom";
import type { PersistedTurnDiffViewState, TurnDiffViewState } from "./model";
export interface TurnDiffViewActions {
copyDiff?: () => void;
}
export function renderTurnDiffView(
parent: HTMLElement,
state: TurnDiffViewState | null,
actions: TurnDiffViewActions = {},
metadata: PersistedTurnDiffViewState | null = null,
): void {
parent.addClass("codex-panel-turn-diff");
renderUiRoot(parent, <TurnDiffView state={state} actions={actions} metadata={metadata} />);
}
function TurnDiffView({
state,
actions,
metadata,
}: {
state: TurnDiffViewState | null;
actions: TurnDiffViewActions;
metadata: PersistedTurnDiffViewState | null;
}): UiNode {
if (!state) {
if (metadata) {
return (
<>
<TurnDiffHeader state={metadata} copyDiff={null} />
<div className="codex-panel-turn-diff__empty">Turn diff is no longer available.</div>
</>
);
}
return <div className="codex-panel-turn-diff__empty">No turn diff selected.</div>;
}
return (
<>
<TurnDiffHeader state={state} copyDiff={actions.copyDiff ?? null} />
{state.files.length > 0 ? <ChangedFiles files={state.files} /> : null}
<UnifiedDiffView diff={state.diff} className="codex-panel-turn-diff__diff" />
</>
);
}
function TurnDiffHeader({
state,
copyDiff,
}: {
state: PersistedTurnDiffViewState;
copyDiff: TurnDiffViewActions["copyDiff"] | null;
}): UiNode {
return (
<div className="codex-panel-turn-diff__header">
<div className="codex-panel-turn-diff__title-block">
<div className="codex-panel-turn-diff__title">Turn diff</div>
<div className="codex-panel-turn-diff__meta">
{shortThreadId(state.threadId)} / {shortThreadId(state.turnId)} ·{" "}
{state.files.length === 1 ? "Edited 1 file" : `Edited ${String(state.files.length)} files`}
</div>
</div>
{copyDiff ? (
<IconButton icon="copy" label="Copy diff" className="clickable-icon codex-panel-turn-diff__copy" onClick={copyDiff} />
) : null}
</div>
);
}
function ChangedFiles({ files }: { files: string[] }): UiNode {
return (
<details className="codex-panel-turn-diff__files">
<summary tabIndex={-1}>Changed files</summary>
<ul>
{files.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
</details>
);
}

View file

@ -1,26 +1,21 @@
import { ItemView, type ViewStateResult } from "obsidian";
import { VIEW_TYPE_CODEX_TURN_DIFF } from "../../../../constants";
import { copyTextWithNotice } from "../../../../shared/ui/clipboard";
import { unmountUiRoot } from "../../../../shared/ui/ui-root.dom";
import {
type ChatTurnDiffViewState,
isPersistedChatTurnDiffViewState,
type PersistedChatTurnDiffViewState,
persistedChatTurnDiffViewState,
} from "../../domain/turn-diff";
import { renderChatTurnDiffView } from "./render.dom";
import { VIEW_TYPE_CODEX_TURN_DIFF } from "../../constants";
import { copyTextWithNotice } from "../../shared/ui/clipboard";
import { unmountUiRoot } from "../../shared/ui/ui-root.dom";
import { isPersistedTurnDiffViewState, type PersistedTurnDiffViewState, persistedTurnDiffViewState, type TurnDiffViewState } from "./model";
import { renderTurnDiffView } from "./render.dom";
export class CodexChatTurnDiffView extends ItemView {
private metadata: PersistedChatTurnDiffViewState | null = null;
private payload: ChatTurnDiffViewState | null = null;
export class CodexTurnDiffView extends ItemView {
private metadata: PersistedTurnDiffViewState | null = null;
private payload: TurnDiffViewState | null = null;
override getViewType(): string {
return VIEW_TYPE_CODEX_TURN_DIFF;
}
override getDisplayText(): string {
return "Codex chat turn diff";
return "Codex turn diff";
}
override getIcon(): string {
@ -33,7 +28,7 @@ export class CodexChatTurnDiffView extends ItemView {
override async setState(state: unknown, result: ViewStateResult): Promise<void> {
await super.setState(state, result);
this.metadata = isPersistedChatTurnDiffViewState(state)
this.metadata = isPersistedTurnDiffViewState(state)
? {
threadId: state.threadId,
turnId: state.turnId,
@ -53,15 +48,15 @@ export class CodexChatTurnDiffView extends ItemView {
unmountUiRoot(this.contentEl);
}
setDiffPayload(payload: ChatTurnDiffViewState): void {
this.metadata = persistedChatTurnDiffViewState(payload);
setDiffPayload(payload: TurnDiffViewState): void {
this.metadata = persistedTurnDiffViewState(payload);
this.payload = payload;
this.render();
}
private render(): void {
const root = this.contentEl;
renderChatTurnDiffView(
renderTurnDiffView(
root,
this.payload,
this.payload ? { copyDiff: () => void this.copyDiff(this.payload?.diff ?? "") } : {},

View file

@ -2,9 +2,9 @@ import { Plugin } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
import { CodexChatView } from "./features/chat/host/view.obsidian";
import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view.obsidian";
import { registerSelectionRewriteCommand } from "./features/selection-rewrite/command.obsidian";
import { CodexThreadsView } from "./features/threads-view/view.obsidian";
import { CodexTurnDiffView } from "./features/turn-diff/view.obsidian";
import { CodexPanelRuntime } from "./plugin-runtime";
import { type CodexPanelSettings, DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchStoredSettings } from "./settings/model";
import { CodexPanelSettingTab } from "./settings/tab.obsidian";
@ -24,7 +24,7 @@ export default class CodexPanelPlugin extends Plugin {
await this.loadSettings();
this.registerView(VIEW_TYPE_CODEX_PANEL, (leaf) => new CodexChatView(leaf, this.runtime.chatHost()));
this.registerView(VIEW_TYPE_CODEX_TURN_DIFF, (leaf) => new CodexChatTurnDiffView(leaf));
this.registerView(VIEW_TYPE_CODEX_TURN_DIFF, (leaf) => new CodexTurnDiffView(leaf));
this.registerView(VIEW_TYPE_CODEX_THREADS, (leaf) => new CodexThreadsView(leaf, this.runtime.threadsHost()));
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {

View file

@ -8,8 +8,6 @@ import { AppServerSharedQueries } from "./app-server/query/shared-queries";
import { createThreadCatalog, type ThreadCatalog, type ThreadCatalogEvent } from "./app-server/query/thread-catalog";
import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
import { hasPendingRequests } from "./domain/pending-requests/aggregate";
import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
import { persistedChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
import type {
ChatPanelClientSurface,
ChatPanelSettingsAccess,
@ -18,11 +16,12 @@ import type {
ChatWorkspacePanelSurface,
CodexChatHost,
} from "./features/chat/host/contracts";
import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view.obsidian";
import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal.obsidian";
import type { ThreadsViewHost, ThreadsViewSettingsAccess } from "./features/threads-view/session";
import type { ThreadsViewPanelActivity } from "./features/threads-view/state";
import { CodexThreadsView } from "./features/threads-view/view.obsidian";
import { persistedTurnDiffViewState, type TurnDiffViewState } from "./features/turn-diff/model";
import { CodexTurnDiffView } from "./features/turn-diff/view.obsidian";
import type { CodexPanelSettingTabHost } from "./settings/host";
import type { CodexPanelSettings } from "./settings/model";
import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator";
@ -200,12 +199,12 @@ export class CodexPanelRuntime implements AppServerClientAccess {
return this.runWithAppServerClient(this.appServerQueryContext(), operation, options);
}
private async openTurnDiff(state: ChatTurnDiffViewState): Promise<void> {
private async openTurnDiff(state: TurnDiffViewState): Promise<void> {
const existing = this.options.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_TURN_DIFF).at(0);
const leaf = existing ?? this.options.app.workspace.getLeaf("tab");
await leaf.setViewState({ type: VIEW_TYPE_CODEX_TURN_DIFF, active: true, state: { ...persistedChatTurnDiffViewState(state) } });
await leaf.setViewState({ type: VIEW_TYPE_CODEX_TURN_DIFF, active: true, state: { ...persistedTurnDiffViewState(state) } });
await leaf.loadIfDeferred();
if (leaf.view instanceof CodexChatTurnDiffView) {
if (leaf.view instanceof CodexTurnDiffView) {
leaf.view.setDiffPayload(state);
}
await this.options.app.workspace.revealLeaf(leaf);

View file

@ -1,5 +1,5 @@
.codex-panel,
.codex-panel-chat-turn-diff,
.codex-panel-turn-diff,
.codex-panel-settings,
.codex-panel-threads,
.codex-panel-selection-rewrite {

View file

@ -1,4 +1,4 @@
.codex-panel-chat-turn-diff {
.codex-panel-turn-diff {
display: flex;
flex-direction: column;
gap: var(--codex-panel-edge-padding-x);
@ -7,52 +7,52 @@
overflow: auto;
}
.codex-panel-chat-turn-diff__header {
.codex-panel-turn-diff__header {
display: flex;
align-items: flex-start;
gap: var(--codex-panel-section-gap);
}
.codex-panel-chat-turn-diff__title-block {
.codex-panel-turn-diff__title-block {
flex: 1 1 auto;
min-width: 0;
}
.codex-panel-chat-turn-diff__title {
.codex-panel-turn-diff__title {
color: var(--text-normal);
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
line-height: var(--line-height-tight);
}
.codex-panel-chat-turn-diff__meta,
.codex-panel-chat-turn-diff__files,
.codex-panel-chat-turn-diff__empty {
.codex-panel-turn-diff__meta,
.codex-panel-turn-diff__files,
.codex-panel-turn-diff__empty {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.codex-panel-chat-turn-diff__copy {
.codex-panel-turn-diff__copy {
flex: 0 0 auto;
}
.codex-panel-chat-turn-diff__files {
.codex-panel-turn-diff__files {
margin: 0;
}
.codex-panel-chat-turn-diff__files > summary {
.codex-panel-turn-diff__files > summary {
cursor: default;
}
.codex-panel-chat-turn-diff__files > summary:hover {
.codex-panel-turn-diff__files > summary:hover {
color: var(--nav-item-color-hover, var(--text-normal));
}
.codex-panel-chat-turn-diff__files ul {
.codex-panel-turn-diff__files ul {
margin-block: var(--codex-panel-control-gap) 0;
}
.codex-panel-chat-turn-diff__diff.codex-panel-diff {
.codex-panel-turn-diff__diff.codex-panel-diff {
max-height: none;
margin: 0;
overflow: visible;

View file

@ -10,7 +10,7 @@
"24-chat-stream-items.css",
"25-chat-pending-requests.css",
"26-chat-composer.css",
"30-chat-turn-diff-view.css",
"30-turn-diff-view.css",
"40-threads-view.css",
"50-selection-rewrite.css",
"60-settings.css"

View file

@ -3,19 +3,19 @@
import type { WorkspaceLeaf } from "obsidian";
import { describe, expect, it, vi } from "vitest";
import { persistedChatTurnDiffViewState } from "../../../../src/features/chat/domain/turn-diff";
import { renderChatTurnDiffView } from "../../../../src/features/chat/ui/turn-diff/render.dom";
import { CodexChatTurnDiffView } from "../../../../src/features/chat/ui/turn-diff/view.obsidian";
import { installObsidianDomShims } from "../../../support/dom";
import { persistedTurnDiffViewState } from "../../../src/features/turn-diff/model";
import { renderTurnDiffView } from "../../../src/features/turn-diff/render.dom";
import { CodexTurnDiffView } from "../../../src/features/turn-diff/view.obsidian";
import { installObsidianDomShims } from "../../support/dom";
installObsidianDomShims();
describe("chat turn diff view decisions", () => {
describe("turn diff view decisions", () => {
it("renders the turn diff view with classified unified diff lines", () => {
const parent = document.createElement("div");
const copyDiff = vi.fn();
renderChatTurnDiffView(
renderTurnDiffView(
parent,
{
threadId: "019e061e-0046-7653-a362-86de9a47cb5c",
@ -27,12 +27,12 @@ describe("chat turn diff view decisions", () => {
{ copyDiff },
);
expect(parent.querySelector(".codex-panel-chat-turn-diff__title")?.textContent).toBe("Turn diff");
expect(parent.querySelector(".codex-panel-chat-turn-diff__meta")?.textContent).toContain("019e061e");
const changedFilesSummary = parent.querySelector<HTMLElement>(".codex-panel-chat-turn-diff__files summary");
expect(parent.querySelector(".codex-panel-turn-diff__title")?.textContent).toBe("Turn diff");
expect(parent.querySelector(".codex-panel-turn-diff__meta")?.textContent).toContain("019e061e");
const changedFilesSummary = parent.querySelector<HTMLElement>(".codex-panel-turn-diff__files summary");
expect(changedFilesSummary?.textContent).toBe("Changed files");
expect(changedFilesSummary?.tabIndex).toBe(-1);
expect(parent.querySelector(".codex-panel-chat-turn-diff__files")?.textContent).toContain("src/main.ts");
expect(parent.querySelector(".codex-panel-turn-diff__files")?.textContent).toContain("src/main.ts");
expect(parent.querySelector(".codex-panel-diff__line--file")?.textContent).toBe("src/main.ts");
expect(parent.textContent).not.toContain("diff --git");
expect(parent.textContent).not.toContain("+++ b/src/main.ts");
@ -43,14 +43,14 @@ describe("chat turn diff view decisions", () => {
expect(parent.querySelectorAll(".codex-panel-diff__line--hunk")).toHaveLength(1);
expect(parent.querySelectorAll(".codex-panel-diff__line--removed")).toHaveLength(1);
expect(parent.querySelectorAll(".codex-panel-diff__line--added")).toHaveLength(1);
parent.querySelector<HTMLButtonElement>(".codex-panel-chat-turn-diff__copy")?.click();
parent.querySelector<HTMLButtonElement>(".codex-panel-turn-diff__copy")?.click();
expect(copyDiff).toHaveBeenCalled();
});
it("highlights changed English words inside adjacent removed and added lines", () => {
const parent = document.createElement("div");
renderChatTurnDiffView(parent, {
renderTurnDiffView(parent, {
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
@ -67,7 +67,7 @@ describe("chat turn diff view decisions", () => {
it("highlights changed Japanese words with Intl.Segmenter", () => {
const parent = document.createElement("div");
renderChatTurnDiffView(parent, {
renderTurnDiffView(parent, {
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
@ -84,7 +84,7 @@ describe("chat turn diff view decisions", () => {
it("pairs changed words by line inside multi-line replacement blocks", () => {
const parent = document.createElement("div");
renderChatTurnDiffView(parent, {
renderTurnDiffView(parent, {
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
@ -114,7 +114,7 @@ describe("chat turn diff view decisions", () => {
const oldText = `start ${"old ".repeat(600)}end`;
const newText = `start ${"new ".repeat(600)}end`;
renderChatTurnDiffView(parent, {
renderTurnDiffView(parent, {
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
@ -128,7 +128,7 @@ describe("chat turn diff view decisions", () => {
});
it("keeps unified diff text out of persisted turn diff view state", () => {
const persisted = persistedChatTurnDiffViewState({
const persisted = persistedTurnDiffViewState({
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
@ -148,17 +148,17 @@ describe("chat turn diff view decisions", () => {
it("renders restored turn diff metadata without unavailable diff text", () => {
const parent = document.createElement("div");
renderChatTurnDiffView(parent, null, {}, { threadId: "thread", turnId: "turn", cwd: "/vault/project", files: ["src/main.ts"] });
renderTurnDiffView(parent, null, {}, { threadId: "thread", turnId: "turn", cwd: "/vault/project", files: ["src/main.ts"] });
expect(parent.querySelector(".codex-panel-chat-turn-diff__meta")?.textContent).toContain("thread / turn");
expect(parent.querySelector(".codex-panel-turn-diff__meta")?.textContent).toContain("thread / turn");
expect(parent.textContent).toContain("Turn diff is no longer available.");
expect(parent.querySelector(".codex-panel-chat-turn-diff__copy")).toBeNull();
expect(parent.querySelector(".codex-panel-chat-turn-diff__diff")).toBeNull();
expect(parent.querySelector(".codex-panel-turn-diff__copy")).toBeNull();
expect(parent.querySelector(".codex-panel-turn-diff__diff")).toBeNull();
});
it("unmounts the turn diff Preact root when the view closes", async () => {
const containerEl = document.createElement("div");
const view = new CodexChatTurnDiffView({ containerEl } as unknown as WorkspaceLeaf);
const view = new CodexTurnDiffView({ containerEl } as unknown as WorkspaceLeaf);
view.setDiffPayload({
threadId: "thread",
@ -168,7 +168,7 @@ describe("chat turn diff view decisions", () => {
diff: "diff --git a/src/main.ts b/src/main.ts\n@@\n-old\n+new",
});
expect(view.contentEl.querySelector(".codex-panel-chat-turn-diff__title")?.textContent).toBe("Turn diff");
expect(view.contentEl.querySelector(".codex-panel-turn-diff__title")?.textContent).toBe("Turn diff");
await view.onClose();

View file

@ -740,6 +740,7 @@ export const value = statusText;
expect(pluginDiagnostics(report, "src/features/chat/ui/message.tsx")).toEqual([]);
expect(pluginDiagnostics(report, "src/features/selection-rewrite/popover.dom.tsx")).toEqual([]);
expect(pluginDiagnostics(report, "src/features/threads-view/shell.dom.tsx")).toEqual([]);
expect(pluginDiagnostics(report, "src/features/turn-diff/render.dom.tsx")).toEqual([]);
expect(pluginDiagnostics(report, "src/settings/section.tsx")).toEqual([]);
expect(pluginDiagnostics(report, "src/shared/ui/diff.tsx")).toEqual([]);
});
@ -950,6 +951,12 @@ export const value = <li />;
path.join(cwd, "src/features/threads-view/shell.dom.tsx"),
`
export const value = <main />;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/features/turn-diff/render.dom.tsx"),
`
export const value = <main />;
`.trimStart(),
);
await writeFile(
@ -1025,6 +1032,7 @@ export const value = <pre />;
"src/features/selection-rewrite/popover.dom.tsx",
"src/features/threads-view/inline-row.tsx",
"src/features/threads-view/shell.dom.tsx",
"src/features/turn-diff/render.dom.tsx",
"src/features/chat/ui/message.tsx",
"src/settings/section.tsx",
"src/shared/ui/diff.tsx",
@ -1367,6 +1375,7 @@ async function tempBiomeWorkspace(plugins) {
await mkdir(path.join(cwd, "src/features/threads/list"), { recursive: true });
await mkdir(path.join(cwd, "src/features/threads/workflows"), { recursive: true });
await mkdir(path.join(cwd, "src/features/threads-view"), { recursive: true });
await mkdir(path.join(cwd, "src/features/turn-diff"), { recursive: true });
await mkdir(path.join(cwd, "src/settings"), { recursive: true });
await mkdir(path.join(cwd, "src/domain/threads"), { recursive: true });
await mkdir(path.join(cwd, "src/app-server/connection"), { recursive: true });

View file

@ -12,7 +12,7 @@ describe("panel CSS token scope", () => {
const tokenScope = /^(?<selectors>(?:\.[^{]+,\n)*\.[^{]+) \{/m.exec(styles)?.groups?.["selectors"] ?? "";
expect(tokenScope).toContain(".codex-panel");
expect(tokenScope).toContain(".codex-panel-chat-turn-diff");
expect(tokenScope).toContain(".codex-panel-turn-diff");
expect(tokenScope).toContain(".codex-panel-settings");
expect(tokenScope).toContain(".codex-panel-threads");
expect(tokenScope).toContain(".codex-panel-selection-rewrite");