summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-22 19:57:51 -0600
committerAdam <[email protected]>2026-01-22 22:12:12 -0600
commit82f718b3cfd19cd79171d240851e29127c9d8077 (patch)
treed3cc51b08038409e23dd75d660addff75b4a9860 /packages
parent0eb523631d6b321960ecbc3893a74d3df086a5d7 (diff)
downloadopencode-82f718b3cfd19cd79171d240851e29127c9d8077.tar.gz
opencode-82f718b3cfd19cd79171d240851e29127c9d8077.zip
wip(app): line selection
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/components/prompt-input.tsx185
-rw-r--r--packages/app/src/context/prompt.tsx12
-rw-r--r--packages/app/src/pages/session.tsx101
-rw-r--r--packages/ui/src/components/session-review.css4
4 files changed, 108 insertions, 194 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 8dc64b428..f0ac47632 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -15,7 +15,7 @@ import {
import { createStore, produce } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
-import { selectionFromLines, useFile, type FileSelection } from "@/context/file"
+import { useFile, type FileSelection } from "@/context/file"
import {
ContentPart,
DEFAULT_PROMPT,
@@ -161,18 +161,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
- const activeFile = createMemo(() => {
- const tab = tabs().active()
- if (!tab) return
- return files.pathFromTab(tab)
- })
+ const recent = createMemo(() => {
+ const all = tabs().all()
+ const active = tabs().active()
+ const order = active ? [active, ...all.filter((x) => x !== active)] : all
+ const seen = new Set<string>()
+ const paths: string[] = []
+
+ for (const tab of order) {
+ const path = files.pathFromTab(tab)
+ if (!path) continue
+ if (seen.has(path)) continue
+ seen.add(path)
+ paths.push(path)
+ }
- const activeFileSelection = createMemo(() => {
- const path = activeFile()
- if (!path) return
- const range = files.selectedLines(path)
- if (!range) return
- return selectionFromLines(range)
+ return paths
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const status = createMemo(
@@ -393,7 +397,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!isFocused()) setComposing(false)
})
- type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string }
+ type AtOption =
+ | { type: "agent"; name: string; display: string }
+ | { type: "file"; path: string; display: string; recent?: boolean }
const agentList = createMemo(() =>
sync.data.agent
@@ -424,12 +430,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} = 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 }))
const paths = await files.searchFilesAndDirectories(query)
- const fileOptions: AtOption[] = paths.map((path) => ({ type: "file", path, display: path }))
- return [...agents, ...fileOptions]
+ const fileOptions: AtOption[] = paths
+ .filter((path) => !seen.has(path))
+ .map((path) => ({ type: "file", path, display: path }))
+ return [...agents, ...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)
+ },
onSelect: handleAtSelect,
})
@@ -1242,37 +1266,67 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
- const contextFileParts: Array<{
- id: string
- type: "file"
- mime: string
- url: string
- filename?: string
- }> = []
-
- const addContextFile = (path: string, selection?: FileSelection) => {
- const absolute = toAbsolutePath(path)
- const query = selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
+ const context = prompt.context.items().slice()
+
+ const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
+
+ const contextParts: Array<
+ | {
+ id: string
+ type: "text"
+ text: string
+ }
+ | {
+ id: string
+ type: "file"
+ mime: string
+ url: string
+ filename?: string
+ }
+ > = []
+
+ const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
+ const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
+ const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
+ const range =
+ start === undefined || end === undefined
+ ? "this file"
+ : start === end
+ ? `line ${start}`
+ : `lines ${start} through ${end}`
+
+ return `The user made the following comment regarding ${range} of ${path}: ${comment}`
+ }
+
+ const addContextFile = (input: { path: string; selection?: FileSelection; comment?: string }) => {
+ const absolute = toAbsolutePath(input.path)
+ const query = input.selection ? `?start=${input.selection.startLine}&end=${input.selection.endLine}` : ""
const url = `file://${absolute}${query}`
- if (usedUrls.has(url)) return
+
+ const comment = input.comment?.trim()
+ if (!comment && usedUrls.has(url)) return
usedUrls.add(url)
- contextFileParts.push({
+
+ if (comment) {
+ contextParts.push({
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: commentNote(input.path, input.selection, comment),
+ })
+ }
+
+ contextParts.push({
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
- filename: getFilename(path),
+ filename: getFilename(input.path),
})
}
- const activePath = activeFile()
- if (activePath && prompt.context.activeTab()) {
- addContextFile(activePath, activeFileSelection())
- }
-
- for (const item of prompt.context.items()) {
+ for (const item of context) {
if (item.type !== "file") continue
- addContextFile(item.path, item.selection)
+ addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
}
const imageAttachmentParts = images.map((attachment) => ({
@@ -1292,7 +1346,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const requestParts = [
textPart,
...fileAttachmentParts,
- ...contextFileParts,
+ ...contextParts,
...agentAttachmentParts,
...imageAttachmentParts,
]
@@ -1345,6 +1399,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
}
+ for (const item of commentItems) {
+ prompt.context.remove(item.key)
+ }
+
clearInput()
addOptimisticMessage()
@@ -1363,6 +1421,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
description: errorMessage(err),
})
removeOptimisticMessage()
+ for (const item of commentItems) {
+ prompt.context.add({
+ type: "file",
+ path: item.path,
+ selection: item.selection,
+ comment: item.comment,
+ commentID: item.commentID,
+ preview: item.preview,
+ })
+ }
restoreInput()
})
}
@@ -1487,49 +1555,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</div>
</Show>
- <Show when={prompt.context.items().length > 0 || !!activeFile()}>
+ <Show when={prompt.context.items().length > 0}>
<div class="flex flex-nowrap items-start gap-1.5 px-3 pt-3 overflow-x-auto no-scrollbar">
- <Show when={prompt.context.activeTab() ? activeFile() : undefined}>
- {(path) => (
- <div class="shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]">
- <div class="flex items-center gap-1.5">
- <FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-3.5" />
- <div class="flex items-center text-11-regular min-w-0">
- <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span>
- <span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span>
- <Show when={activeFileSelection()}>
- {(sel) => (
- <span class="text-text-weak whitespace-nowrap ml-1">
- {sel().startLine === sel().endLine
- ? `:${sel().startLine}`
- : `:${sel().startLine}-${sel().endLine}`}
- </span>
- )}
- </Show>
- <span class="text-text-weak whitespace-nowrap ml-1">{language.t("prompt.context.active")}</span>
- </div>
- <IconButton
- type="button"
- icon="close"
- variant="ghost"
- class="h-5 w-5"
- onClick={() => prompt.context.removeActive()}
- aria-label={language.t("prompt.context.removeActiveFile")}
- />
- </div>
- </div>
- )}
- </Show>
- <Show when={!prompt.context.activeTab() && !!activeFile()}>
- <button
- type="button"
- class="shrink-0 flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base text-11-regular text-text-weak hover:bg-surface-raised-base-hover"
- onClick={() => prompt.context.addActive()}
- >
- <Icon name="plus-small" size="small" />
- <span>{language.t("prompt.context.includeActiveFile")}</span>
- </button>
- </Show>
<For each={prompt.context.items()}>
{(item) => {
const preview = createMemo(() => selectionPreview(item.path, item.selection, item.preview))
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 40baa0ef5..6b9d5cc0d 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -122,14 +122,12 @@ function createPromptSession(dir: string, id: string | undefined) {
prompt: Prompt
cursor?: number
context: {
- activeTab: boolean
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
- activeTab: true,
items: [],
},
}),
@@ -157,14 +155,7 @@ function createPromptSession(dir: string, id: string | undefined) {
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
context: {
- activeTab: createMemo(() => store.context.activeTab),
items: createMemo(() => store.context.items),
- addActive() {
- setStore("context", "activeTab", true)
- },
- removeActive() {
- setStore("context", "activeTab", false)
- },
add(item: ContextItem) {
const key = keyForItem(item)
if (store.context.items.find((x) => x.key === key)) return
@@ -243,10 +234,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
cursor: () => session().cursor(),
dirty: () => session().dirty(),
context: {
- activeTab: () => session().context.activeTab(),
items: () => session().context.items(),
- addActive: () => session().context.addActive(),
- removeActive: () => session().context.removeActive(),
add: (item: ContextItem) => session().context.add(item),
remove: (key: string) => session().context.remove(key),
},
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 96de3f117..9470a032f 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1810,8 +1810,6 @@ export default function Page() {
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
- const [selectionPopoverTop, setSelectionPopoverTop] = createSignal<number | undefined>()
-
const path = createMemo(() => file.pathFromTab(tab))
const state = createMemo(() => {
const p = path()
@@ -1855,17 +1853,6 @@ export default function Page() {
if (file.ready()) return file.selectedLines(p) ?? null
return handoff.files[p] ?? null
})
- const selection = createMemo(() => {
- const range = selectedLines()
- if (!range) return
- return selectionFromLines(range)
- })
- const selectionLabel = createMemo(() => {
- const sel = selection()
- if (!sel) return
- if (sel.startLine === sel.endLine) return `L${sel.startLine}`
- return `L${sel.startLine}-${sel.endLine}`
- })
let wrap: HTMLDivElement | undefined
let textarea: HTMLTextAreaElement | undefined
@@ -1991,7 +1978,6 @@ export default function Page() {
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
- requestAnimationFrame(updateSelectionPopover)
requestAnimationFrame(scheduleComments)
}}
onLineSelected={(range: SelectedLineRange | null) => {
@@ -2119,61 +2105,6 @@ export default function Page() {
</div>
)
- const updateSelectionPopover = () => {
- const el = scroll
- if (!el) {
- setSelectionPopoverTop(undefined)
- return
- }
-
- const sel = selection()
- if (!sel) {
- setSelectionPopoverTop(undefined)
- return
- }
-
- const host = el.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) {
- setSelectionPopoverTop(undefined)
- return
- }
-
- const root = host.shadowRoot
- if (!root) {
- setSelectionPopoverTop(undefined)
- return
- }
-
- const marker =
- (root.querySelector(
- '[data-selected-line="last"], [data-selected-line="single"]',
- ) as HTMLElement | null) ?? (root.querySelector("[data-selected-line]") as HTMLElement | null)
-
- if (!marker) {
- setSelectionPopoverTop(undefined)
- return
- }
-
- const containerRect = el.getBoundingClientRect()
- const markerRect = marker.getBoundingClientRect()
- setSelectionPopoverTop(markerRect.bottom - containerRect.top + el.scrollTop + 8)
- }
-
- createEffect(
- on(
- selection,
- (sel) => {
- if (!sel) {
- setSelectionPopoverTop(undefined)
- return
- }
-
- requestAnimationFrame(updateSelectionPopover)
- },
- { defer: true },
- ),
- )
-
const getCodeScroll = () => {
const el = scroll
if (!el) return []
@@ -2312,41 +2243,9 @@ export default function Page() {
ref={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
- updateSelectionPopover()
}}
onScroll={handleScroll}
>
- <Show when={activeTab() === tab}>
- <Show when={selectionPopoverTop() !== undefined && selection()}>
- {(sel) => (
- <div class="absolute z-20 right-6" style={{ top: `${selectionPopoverTop() ?? 0}px` }}>
- <TooltipKeybind
- placement="bottom"
- title="Add selection to context"
- keybind={command.keybind("context.addSelection")}
- >
- <button
- type="button"
- class="group relative flex items-center gap-2 h-6 px-2.5 rounded-md bg-surface-raised-stronger-non-alpha border border-border-weak-base text-12-medium text-text-strong shadow-xs-border whitespace-nowrap hover:bg-surface-raised-stronger-hover hover:border-border-hover focus:outline-none focus-visible:shadow-xs-border-focus"
- onClick={() => {
- const p = path()
- if (!p) return
- addSelectionToContext(p, sel())
- }}
- >
- <span class="pointer-events-none absolute -left-1 top-1/2 size-2.5 -translate-y-1/2 rotate-45 bg-surface-raised-stronger-non-alpha border-l border-b border-border-weak-base group-hover:bg-surface-raised-stronger-hover group-hover:border-border-hover" />
- <Icon name="plus-small" size="small" />
- <span>
- {language.t("session.context.addToContext", {
- selection: selectionLabel() ?? "",
- })}
- </span>
- </button>
- </TooltipKeybind>
- </div>
- )}
- </Show>
- </Show>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css
index f34c8b446..b5eb78753 100644
--- a/packages/ui/src/components/session-review.css
+++ b/packages/ui/src/components/session-review.css
@@ -79,7 +79,7 @@
position: absolute;
top: 0;
right: calc(100% + 12px);
- z-index: 40;
+ z-index: 6;
min-width: 200px;
max-width: min(320px, calc(100vw - 48px));
border-radius: var(--radius-md);
@@ -223,7 +223,7 @@
[data-slot="session-review-comment-anchor"] {
position: absolute;
right: 12px;
- z-index: 30;
+ z-index: 5;
}
[data-slot="session-review-comment-button"] {