Skip to content
Open
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
1 change: 1 addition & 0 deletions web/mobile/src/features/chat/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export const Composer = ({
<ChatComposer
inputRef={richInputRef}
onSubmit={submit}
fileMentions
attachments={attachments}
attachmentsBlocked={attachmentsBlocked}
disabled={disabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ const AgentComposerDock = ({
waitingOnUser={hitlPending}
initialMarkdown={composer.initialDraft}
slashCommands={slash.sections}
fileMentions={!onboardingActive}
onChange={composer.handleComposerChange}
streaming={busy}
onStop={onStop}
Expand Down
117 changes: 117 additions & 0 deletions web/packages/agenta-chat/src/assets/filePaletteRows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Rows for the composer's `@` file palette, derived from a flat drive listing.
*
* Pure and React-free: the palette wants a short, flat, capped list per state, so it derives one
* directly rather than building the explorer's tree and flattening it back down.
*/
import {cleanPath, isListableDrivePath, type DriveRecentFile} from "@agenta/entities/drive"
import type {MountFile} from "@agenta/entities/session"

/** Enough rows to scroll through, few enough that a 12k-file drive never renders 12k of them. */
export const FILE_PALETTE_ROW_CAP = 30
export const FILE_PALETTE_RECENTS = 5

export interface PaletteFileRow {
/** The presented drive path — what gets referenced, `agent-files/` fold included. */
path: string
name: string
isFolder: boolean
size?: number
itemCount?: number
touchedAt?: number
}

const prefixFor = (cwd: string): string => {
const rel = cleanPath(cwd)
return rel ? `${rel}/` : ""
}

const basename = (path: string): string => path.split("/").pop() ?? path

/** Folders above files, then alphabetical — the order every drive surface lists in. */
const byFolderThenName = (a: PaletteFileRow, b: PaletteFileRow): number =>
Number(b.isFolder) - Number(a.isFolder) || a.name.localeCompare(b.name)

const rowFor = (file: MountFile, path: string, isFolder: boolean): PaletteFileRow => ({
path,
name: basename(path),
isFolder,
size: isFolder ? undefined : (file.size ?? undefined),
itemCount: file.item_count ?? undefined,
})

/**
* The immediate children of `cwd`. A listing may hold deeper entries (every loaded directory
* accumulates into one array), so a path that reaches further down contributes its next segment as
* an implied folder rather than a row of its own.
*/
export function browseRows(files: MountFile[], cwd: string): PaletteFileRow[] {
const prefix = prefixFor(cwd)
const rows = new Map<string, PaletteFileRow>()
for (const file of files) {
const rel = cleanPath(file.path)
if (!rel.startsWith(prefix)) continue
const rest = rel.slice(prefix.length)
if (!rest) continue
// Judge the LISTING entry, not the folder it implies: the bare `agent-files` marker is
// unlistable, but the agent files under it fold into a folder that very much is.
if (!isListableDrivePath(rel)) continue
const cut = rest.indexOf("/")
const path = cut < 0 ? rel : prefix + rest.slice(0, cut)
const isFolder = cut >= 0 || Boolean(file.is_folder)
// An implied folder must not inherit the descendant's size or count.
const existing = rows.get(path)
if (existing && (existing.isFolder || !isFolder)) continue
rows.set(
path,
cut < 0 ? rowFor(file, path, isFolder) : {path, name: basename(path), isFolder: true},
)
}
return [...rows.values()].sort(byFolderThenName)
}

/**
* Everything under `cwd` whose path contains `query`. Plain case-insensitive substring on the full
* path — the palette highlights the match by re-deriving it from the row's label, so a looser
* predicate here returns rows that come back matched but unhighlighted.
*/
export function searchRows(
files: MountFile[],
cwd: string,
query: string,
cap: number = FILE_PALETTE_ROW_CAP,
): PaletteFileRow[] {
const q = query.trim().toLowerCase()
if (!q) return []
const prefix = prefixFor(cwd)
const rows: PaletteFileRow[] = []
for (const file of files) {
const rel = cleanPath(file.path)
if (!rel.startsWith(prefix) || !isListableDrivePath(rel)) continue
if (!rel.toLowerCase().includes(q)) continue
rows.push(rowFor(file, rel, Boolean(file.is_folder)))
}
return rows.sort(byFolderThenName).slice(0, cap)
}

/** The drive's most-recently-touched files, for the palette's opening list. */
export function recentRows(
recents: DriveRecentFile[],
limit: number = FILE_PALETTE_RECENTS,
): PaletteFileRow[] {
const rows: PaletteFileRow[] = []
for (const file of recents) {
if (rows.length >= limit) break
const rel = cleanPath(file.path)
if (!isListableDrivePath(rel) || file.is_folder) continue
rows.push({...rowFor(file, rel, false), touchedAt: file.touchedAt})
}
return rows
}

/** The directory a path sits in, as a presented drive path (`""` at the root). */
export const parentPath = (path: string): string => {
const rel = cleanPath(path)
const cut = rel.lastIndexOf("/")
return cut < 0 ? "" : rel.slice(0, cut)
}
1 change: 1 addition & 0 deletions web/packages/agenta-chat/src/assets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from "./conversationLayout"
export * from "./jumpToLatest"
export * from "./boundedRequest"
export {startupLabelFromDataPart} from "./startupPhases"
export * from "./filePaletteRows"
11 changes: 11 additions & 0 deletions web/packages/agenta-chat/src/components/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {Paperclip} from "@phosphor-icons/react"

import {acceptAttrFor} from "../assets/attachmentRules"
import type {useComposerAttachments} from "../hooks/useComposerAttachments"
import {useFilePalette} from "../hooks/useFilePalette"
import {useHardwareKeyboard} from "../hooks/useHardwareKeyboard"

import ComposerAttachments from "./ComposerAttachments"
Expand Down Expand Up @@ -65,6 +66,11 @@ export interface ChatComposerProps {
trailing?: ReactNode
/** The `/` palette's sections. Omit where the surface has no commands. */
slashCommands?: SlashCommandSection[]
/**
* Enable the `@` file palette. Needs an enclosing `DriveSessionProvider`; off by default so the
* surfaces that run before a session exists (onboarding, the home task composer) are untouched.
*/
fileMentions?: boolean
/** Suspense fallback while the Lexical chunk hydrates (hosts pass their skeleton). */
fallback?: ReactNode
}
Expand All @@ -91,6 +97,7 @@ export const ChatComposer = ({
extraPrefix,
trailing,
slashCommands,
fileMentions,
fallback,
}: ChatComposerProps) => {
const {
Expand All @@ -113,9 +120,12 @@ export const ChatComposer = ({
// take room from a placeholder that is already tight. `isMacPlatform` reads the UA, so a real
// iPhone was being shown the `⌘` variant specifically.
const hasKeyboard = useHardwareKeyboard()
const filePalette = useFilePalette({enabled: fileMentions})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<Suspense fallback={fallback ?? null}>
{/* Renders null; it holds the `@` palette's per-directory listings. */}
{filePalette.subscribers}
<input
ref={fileInputRef}
type="file"
Expand Down Expand Up @@ -157,6 +167,7 @@ export const ChatComposer = ({
}
initialMarkdown={initialMarkdown}
slashCommands={slashCommands}
filePalette={filePalette.spec}
onChange={onChange}
onPasteFile={(pasted) => {
if (!attachmentsBlocked?.()) addFiles(Array.from(pasted))
Expand Down
1 change: 1 addition & 0 deletions web/packages/agenta-chat/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export * from "./useVoiceComposer"
export * from "./useSessionChat"
export * from "./useTypewriter"
export * from "./useHardwareKeyboard"
export * from "./useFilePalette"
Loading
Loading