Skip to content
Merged
37 changes: 6 additions & 31 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -574,25 +574,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
}

const agentList = createMemo(() =>
sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
)

const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
if (option.type === "agent") {
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
} else {
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
}
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
Comment thread
Astro-Han marked this conversation as resolved.
}

const atKey = (x: AtOption | undefined) => {
if (!x) return ""
return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
}
const atKey = (x: AtOption | undefined) => x?.path ?? ""

const {
flat: atFlat,
Expand All @@ -602,32 +589,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({
items: async (query) => {
const agents = agentList()
const open = recent()
const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
if (!query.trim()) return [...agents, ...pinned]
if (!query.trim()) return pinned
const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path))
.map((path) => ({ type: "file", path, display: path }))
return [...agents, ...pinned, ...fileOptions]
return [...pinned, ...fileOptions]
},
key: atKey,
filterKeys: ["display"],
groupBy: (item) => {
if (item.type === "agent") return "agent"
if (item.recent) return "recent"
return "file"
},
sortGroupsBy: (a, b) => {
const rank = (category: string) => {
if (category === "agent") return 0
if (category === "recent") return 1
return 2
}
return rank(a.category) - rank(b.category)
},
groupBy: (item) => (item.recent ? "recent" : "file"),
sortGroupsBy: (a, b) => (a.category === "recent" ? -1 : b.category === "recent" ? 1 : 0),
onSelect: handleAtSelect,
})

Expand Down
19 changes: 1 addition & 18 deletions packages/app/src/components/prompt-input/slash-popover.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { Component, For, Match, Show, Switch } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"

export type AtOption =
| { type: "agent"; name: string; display: string }
| { type: "file"; path: string; display: string; recent?: boolean }
export type AtOption = { type: "file"; path: string; display: string; recent?: boolean }

export interface SlashCommand {
id: string
Expand Down Expand Up @@ -56,20 +53,6 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
{(item) => {
const key = props.atKey(item)

if (item.type === "agent") {
return (
<button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
</button>
)
}

const isDirectory = item.path.endsWith("/")
const directory = isDirectory ? item.path : getDirectory(item.path)
const filename = isDirectory ? "" : getFilename(item.path)
Expand Down
78 changes: 78 additions & 0 deletions packages/app/src/no-mode-picker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { test, expect } from "bun:test"
import * as fs from "node:fs/promises"
import * as path from "node:path"

const APP_SRC = __dirname
// packages/app/src → up 2 to packages → into ui/src/components
const UI_COMPONENTS = path.resolve(__dirname, "..", "..", "ui", "src", "components")

// Allowlist: legitimate references to mode === "primary" outside the picker context.
// Each entry: { file: relative-to-APP_SRC, line: 1-based, reason: short justification }.
// Add entries here only when you have read the line and confirmed it is not picker-related.
const MODE_PRIMARY_ALLOWLIST: { file: string; line: number; reason: string }[] = [
{
file: "context/global-sync/utils.ts",
line: 15,
reason: "type guard isAgent: validates agent shape, accepts any of subagent|primary|all (not picker logic)",
},
]

async function walk(dir: string, acc: string[] = []): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue
await walk(full, acc)
} else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) {
acc.push(full)
}
}
return acc
}

test("i18n bundles contain no primary-agent / mode-picker copy", async () => {
const zh = await fs.readFile(path.join(APP_SRC, "i18n", "zh.ts"), "utf8")
const en = await fs.readFile(path.join(APP_SRC, "i18n", "en.ts"), "utf8")
const re = /primary agent|default agent|agent mode|mode picker/i
expect(zh).not.toMatch(re)
expect(en).not.toMatch(re)
})

test('no source file in packages/app/src uses mode === "primary" outside the allowlist', async () => {
const files = await walk(APP_SRC)
const re = /mode\s*[!=]==?\s*['"]primary['"]/
const offenders: { file: string; line: number; text: string }[] = []
for (const file of files) {
const text = await fs.readFile(file, "utf8")
const lines = text.split(/\r?\n/)
lines.forEach((lineText, i) => {
if (!re.test(lineText)) return
const relPath = path.relative(APP_SRC, file)
const ok = MODE_PRIMARY_ALLOWLIST.some((a) => a.file === relPath && a.line === i + 1)
if (!ok) offenders.push({ file: relPath, line: i + 1, text: lineText.trim() })
})
}
if (offenders.length > 0) {
const summary = offenders.map((o) => ` ${o.file}:${o.line} ${o.text}`).join("\n")
throw new Error(
`Found ${offenders.length} mode === "primary" reference(s) in packages/app/src not in MODE_PRIMARY_ALLOWLIST:\n${summary}\n\nIf the reference is legitimate (not picker-related), add it to MODE_PRIMARY_ALLOWLIST in this test file with a one-line reason.`,
)
}
})

test("agentList memo is gone from prompt-input.tsx", async () => {
const file = path.join(APP_SRC, "components", "prompt-input.tsx")
const text = await fs.readFile(file, "utf8")
expect(text).not.toContain("agentList")
})

test("message-part.tsx no longer renders agent pill", async () => {
// After Task 5, HighlightedText drops agents from allRefs and the type union no
// longer includes "agent". Source-grep guards against future regressions that
// re-introduce a styled pill via the same data-highlight marker.
const file = path.join(UI_COMPONENTS, "message-part.tsx")
const text = await fs.readFile(file, "utf8")
// Match data-highlight="agent" / 'agent' / `agent` to survive quote-style changes.
expect(text).not.toMatch(/data-highlight\s*=\s*["'`]agent["'`]/)
})
102 changes: 102 additions & 0 deletions packages/app/src/utils/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,106 @@ describe("extractPromptFromParts", () => {
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
])
})

test("issue #239: AgentPart in history restores as plain text, not as an agent inline", () => {
// Pre-#239 messages may contain a separate AgentPart record beside the text
// that already includes "@<name>" inline. After #239 the picker is gone, so
// the AgentPart must be ignored and the @<name> substring should restore as
// plain text from the text part.
const parts = [
{
id: "text_1",
type: "text",
text: "ask @researcher to look at this",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "agent_1",
type: "agent",
name: "researcher",
source: { value: "@researcher", start: 4, end: 15 },
sessionID: "ses_1",
messageID: "msg_1",
},
] satisfies Part[]

const result = extractPromptFromParts(parts)

// No agent inline reconstructed
expect(result.some((p) => p.type === "agent")).toBe(false)

// The full original text (including the literal "@researcher") restores from
// the text part as a single plain-text inline
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ type: "text", content: "ask @researcher to look at this" })
})

test("issue #239: AgentPart between file references does not disturb file offsets", () => {
// File part offsets in the surrounding text must not shift even when an
// AgentPart sits between them. The agent record is dropped entirely;
// file inlines occupy their original positions.
const parts = [
{
id: "text_1",
type: "text",
text: "open @a.ts then @bot then @b.ts",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_a",
type: "file",
mime: "text/plain",
url: "file:///workspace/a.ts",
source: {
type: "file",
path: "/workspace/a.ts",
text: { value: "@a.ts", start: 5, end: 10 },
},
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "agent_1",
type: "agent",
name: "bot",
source: { value: "@bot", start: 16, end: 20 },
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_b",
type: "file",
mime: "text/plain",
url: "file:///workspace/b.ts",
source: {
type: "file",
path: "/workspace/b.ts",
text: { value: "@b.ts", start: 26, end: 31 },
},
sessionID: "ses_1",
messageID: "msg_1",
},
] satisfies Part[]

const result = extractPromptFromParts(parts)

// No agent in result
expect(result.some((p) => p.type === "agent")).toBe(false)

// File parts are present at their original offsets; @bot stays inside text
const files = result.filter((p) => p.type === "file")
expect(files).toHaveLength(2)
// path strips the leading "@" from the source.text.value (extractor convention)
expect(files[0]).toMatchObject({ type: "file", path: "a.ts", start: 5, end: 10 })
expect(files[1]).toMatchObject({ type: "file", path: "b.ts", start: 26, end: 31 })

// @bot stays as plain text in the surrounding text inlines
const text = result
.filter((p) => p.type === "text")
.map((p) => p.content)
.join("")
expect(text).toContain("@bot")
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
35 changes: 7 additions & 28 deletions packages/app/src/utils/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import type { FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"

type Inline =
| {
Expand Down Expand Up @@ -112,18 +112,11 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin
}
}

if (part.type === "agent") {
const agentPart = part as MessageAgentPart
const source = agentPart.source
if (!source) continue
inline.push({
type: "agent",
start: source.start,
end: source.end,
value: source.value,
name: agentPart.name,
})
}
// PawWork issue #239: AgentPart records from history are intentionally NOT
// converted to inline agent pills. The original `@<name>` substring is
// already in the surrounding text part, so it restores as plain text.
// This single point also defuses buildRequestParts (no AgentPartInput
// submitted) and renderEditor (no pill).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

inline.sort((a, b) => {
Expand Down Expand Up @@ -160,19 +153,6 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin
position += content.length
}

const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
const content = item.value
const mention: AgentPart = {
type: "agent",
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(mention)
position += content.length
}

for (const item of inline) {
if (item.start < 0 || item.end < item.start) continue

Expand All @@ -187,7 +167,6 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin
pushText(text.slice(cursor, start))

if (item.type === "file") pushFile(item)
if (item.type === "agent") pushAgent(item)

cursor = end
}
Expand Down
Loading
Loading