Files
coder/coderd/x/chatd/chattool
Michael Suchacz 2e5353bde7 feat: add built-in Browser tab for agent-browser (#27910)
Adds a built-in Browser tab to the Agents page right panel, alongside
the built-in Terminal and Desktop tabs, when the chat's bound agent has
an app with the well-known slug `agent-browser`. The tab shows only
while the app is embeddable and its health is `healthy` (or `disabled`,
for templates without a healthcheck), so it appears and disappears live
as the daemon comes up or goes down. The iframe stays mounted across tab
switches to preserve session state.

To avoid duplicates, the generic Add Tab menu and persisted
workspace-app tabs now exclude the `agent-browser` app. Detection uses
the existing `coder_app` slug and healthcheck signals already present in
the workspace data model. The workspace watch handler compares the agent
app fields the chat UI consumes, so health transitions propagate without
re-render churn on every heartbeat.

On the backend, the chat `execute` tool now exports
`AGENT_BROWSER_SESSION=<chat id>` on every process it starts.
agent-browser resolves its default session from that variable, so
browser automation from each chat lands in its own isolated session
(named by the chat id in the embedded dashboard) instead of a shared
default browser.

> Mux created this PR on Mike's behalf.
2026-08-11 12:49:12 +02:00
..

package chattool

import (
	"bufio"
	"strings"
	"unicode"

	"github.com/coder/coder/v2/coderd/render"
	coderstrings "github.com/coder/coder/v2/coderd/util/strings"
)

// readmeInputMaxBytes caps how many README bytes are parsed so a giant README
// can't OOM coderd. It sits well above the output rune cap, so it never trims a
// real excerpt.
const readmeInputMaxBytes = 64 * 1024

// readmeText returns the README as bounded, frontmatter-stripped plain text
// truncated to maxRunes, or "" when the README is blank or conversion fails.
func readmeText(readme string, maxRunes int) string {
	// Cap the parse input first (see readmeInputMaxBytes); goldmark and the
	// tokenizer tolerate a mid-line or mid-rune cut.
	bounded := readme[:min(len(readme), readmeInputMaxBytes)]
	text, err := render.InnerTextFromMarkdown(stripReadmeFrontmatter(bounded))
	if err != nil {
		return ""
	}
	return coderstrings.Truncate(text, maxRunes, coderstrings.TruncateWithEllipsis)
}

// stripReadmeFrontmatter strips a leading frontmatter block from the README if it
// exists. An unterminated frontmatter block is treated as a regular body section.
// UTF-8 BOMs are stripped if present, and CRLF is normalized to LF. Leading
// whitespace is also stripped.
func stripReadmeFrontmatter(readme string) string {
	trimmed := strings.TrimLeftFunc(readme, func(r rune) bool {
		return unicode.IsSpace(r) || r == '\ufeff'
	})

	var out strings.Builder
	scn := bufio.NewScanner(strings.NewReader(trimmed))
	scn.Buffer(nil, readmeInputMaxBytes+1) // headroom
	var lineNumber int
	var fences int
	for scn.Scan() {
		line := scn.Text()
		lineNumber++

		// Only handle fences if we haven't already found an
		// opening and closing fence.
		if fences < 2 {
			isFence := strings.TrimRight(line, " \t\r") == "---"
			if isFence {
				fences++
				continue
			} else if lineNumber == 1 {
				// No leading fence -> no frontmatter. Return the entire document.
				return trimmed
			}
			// We are still in the frontmatter block. Skip writing this line.
			continue
		}
		_, _ = out.WriteString(line)
		_, _ = out.WriteString("\n")
	}

	// Can err if input is too big. Shouldn't happen normally.
	if scn.Err() != nil {
		return trimmed
	}

	// Scanner did not scan any lines. No frontmatter to strip.
	if lineNumber == 0 {
		return trimmed
	}

	// If we are still fenced but we reached the end of the document
	// we have an unterminated fence.
	if fences == 1 {
		return trimmed
	}
	return out.String()
}