Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "codeg",
"private": true,
"version": "0.15.13",
"version": "0.15.14",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "codeg"
version = "0.15.13"
version = "0.15.14"
description = "Agent Code Generation App"
authors = ["feitao"]
edition = "2021"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "codeg",
"version": "0.15.13",
"version": "0.15.14",
"identifier": "app.codeg",
"build": {
"beforeDevCommand": "pnpm tauri:before-dev",
Expand Down
199 changes: 198 additions & 1 deletion src/components/chat/message-input.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {
render,
screen,
waitFor,
within,
cleanup,
fireEvent,
act,
} from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { NextIntlClientProvider } from "next-intl"
import type { ComponentProps } from "react"
import type { Editor } from "@tiptap/core"
Expand Down Expand Up @@ -71,9 +74,30 @@ vi.mock("@/lib/platform", () => ({
vi.mock("@/lib/transport", () => ({
getActiveRemoteConnectionId: () => null,
}))
// virtua renders 0 rows under jsdom — render children directly so the large
// (searchable + virtualized) model list is exercisable here too.
vi.mock("virtua", async () => {
const { forwardRef, useImperativeHandle } = await import("react")
return {
VList: forwardRef(function VListMock(
props: { children: React.ReactNode; role?: string; id?: string },
ref: React.Ref<{ scrollToIndex: () => void }>
) {
useImperativeHandle(ref, () => ({ scrollToIndex: () => {} }))
return (
<div role={props.role} id={props.id}>
{props.children}
</div>
)
}),
}
})

import enMessages from "@/i18n/messages/en.json"
import type { PromptCapabilitiesInfo } from "@/lib/types"
import type {
PromptCapabilitiesInfo,
SessionConfigOptionInfo,
} from "@/lib/types"

import { MessageInput } from "./message-input"

Expand Down Expand Up @@ -199,3 +223,176 @@ describe("MessageInput attach-to-chat insertion position", () => {
assertBetweenHelloAndWorld(editor.getMarkdown(), link)
})
})

// When the composer is narrow the model/config/mode selectors collapse behind a
// cog button into a single Popover that renders a master–detail panel: the
// settings on the left, the active setting's options (plain buttons) on the
// right. This is the WebKit-safe replacement for the old nested dropdown/submenu
// — a nested Radix dismissable layer drops the selection on WKWebView, so the
// options are plain <button>s in the one popover layer. jsdom has no layout, so
// the container-query-hidden wide row stays hidden and this collapsed path is
// what renders here.
const MODEL_OPTION: SessionConfigOptionInfo = {
id: "model",
name: "Model",
description: "Pick the model",
category: null,
kind: {
type: "select",
current_value: "default",
options: [
{ value: "default", name: "Default", description: "Use the default" },
{ value: "opus", name: "Opus", description: "Most capable" },
],
groups: [],
},
}

describe("MessageInput collapsed selectors popover", () => {
afterEach(() => cleanup())

it("selects a config option from the cog Popover and closes it", async () => {
const user = userEvent.setup()
const onConfigOptionChange = vi.fn()
const { container } = renderInput({
configOptions: [MODEL_OPTION],
onConfigOptionChange,
})
await waitFor(() =>
expect(container.querySelector('[role="textbox"]')).not.toBeNull()
)

const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings
await user.click(screen.getByRole("button", { name: settingsLabel }))

const popover = await screen.findByRole("dialog", { name: settingsLabel })
// The left rail shows the setting as a title + current value row.
expect(
within(popover).getByRole("button", { name: /Model/ })
).toBeInTheDocument()

// Options are plain buttons (native clicks) — selecting fires the change.
await user.click(within(popover).getByRole("button", { name: /Opus/ }))
expect(onConfigOptionChange).toHaveBeenCalledWith("model", "opus")

// Selecting a value closes the controlled popover.
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: settingsLabel })).toBeNull()
)
})

it("groups model values by their provider prefix in the cog Popover", async () => {
const user = userEvent.setup()
const onConfigOptionChange = vi.fn()
const groupedModel: SessionConfigOptionInfo = {
id: "model",
name: "Model",
description: "Pick the model",
category: null,
kind: {
type: "select",
current_value: "anthropic/claude-opus",
options: [
{
value: "anthropic/claude-opus",
name: "anthropic/claude-opus",
description: null,
},
{ value: "openai/gpt-4o", name: "openai/gpt-4o", description: null },
],
groups: [],
},
}
const { container } = renderInput({
configOptions: [groupedModel],
onConfigOptionChange,
})
await waitFor(() =>
expect(container.querySelector('[role="textbox"]')).not.toBeNull()
)

const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings
await user.click(screen.getByRole("button", { name: settingsLabel }))
const popover = await screen.findByRole("dialog", { name: settingsLabel })

// The detail pane carries one header per provider namespace…
expect(within(popover).getByText("anthropic")).toBeInTheDocument()
expect(within(popover).getByText("openai")).toBeInTheDocument()

// …and the option label drops the redundant `openai/` prefix, while the
// committed value stays the full id. (Pick the non-current model so its
// label is unique to the detail pane, not echoed in the left-rail summary.)
await user.click(within(popover).getByRole("button", { name: /gpt-4o/ }))
expect(onConfigOptionChange).toHaveBeenCalledWith("model", "openai/gpt-4o")
})

it("uses a searchable virtualized list for a long model list", async () => {
const user = userEvent.setup()
const onConfigOptionChange = vi.fn()
const options = Array.from({ length: 30 }, (_, i) => ({
value: `openrouter/model-${i}`,
name: `openrouter/model-${i}`,
description: null,
}))
const bigModel: SessionConfigOptionInfo = {
id: "model",
name: "Model",
description: null,
category: null,
kind: {
type: "select",
current_value: "openrouter/model-0",
options,
groups: [],
},
}
const { container } = renderInput({
configOptions: [bigModel],
onConfigOptionChange,
})
await waitFor(() =>
expect(container.querySelector('[role="textbox"]')).not.toBeNull()
)

const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings
await user.click(screen.getByRole("button", { name: settingsLabel }))
const popover = await screen.findByRole("dialog", { name: settingsLabel })

// A long list (> threshold) renders the searchable combobox, not plain rows.
const search = within(popover).getByRole("combobox")
await user.type(search, "model-17")
// Filtering narrows to the one match; the full id is committed on click.
await user.click(within(popover).getByRole("option", { name: /model-17/ }))
expect(onConfigOptionChange).toHaveBeenCalledWith(
"model",
"openrouter/model-17"
)
})

it("selects a mode from the cog Popover and closes it", async () => {
const user = userEvent.setup()
const onModeChange = vi.fn()
const { container } = renderInput({
modes: [
{ id: "plan", name: "Plan", description: "Plan first" },
{ id: "act", name: "Act", description: "Act now" },
],
selectedModeId: "plan",
onModeChange,
})
await waitFor(() =>
expect(container.querySelector('[role="textbox"]')).not.toBeNull()
)

const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings
await user.click(screen.getByRole("button", { name: settingsLabel }))

const popover = await screen.findByRole("dialog", { name: settingsLabel })
await user.click(within(popover).getByRole("button", { name: /Act/ }))
expect(onModeChange).toHaveBeenCalledWith("act")

await waitFor(() =>
expect(screen.queryByRole("dialog", { name: settingsLabel })).toBeNull()
)
})
})
Loading
Loading