summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-21 06:17:55 -0600
committerAdam <[email protected]>2026-01-22 22:12:12 -0600
commitcb481d9ac861813d4ff091ed33bcac9e882da1a1 (patch)
treec08be4b96815b74ac6dc1e3bab6359cd5dbb27b3 /packages/app/src
parent0ce0cacb282c47943348a2af21ea00e721bcb9d9 (diff)
downloadopencode-cb481d9ac861813d4ff091ed33bcac9e882da1a1.tar.gz
opencode-cb481d9ac861813d4ff091ed33bcac9e882da1a1.zip
wip(app): line selection
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/app.tsx17
-rw-r--r--packages/app/src/components/prompt-input.tsx21
-rw-r--r--packages/app/src/context/comments.tsx140
-rw-r--r--packages/app/src/context/prompt.tsx6
-rw-r--r--packages/app/src/pages/session.tsx20
5 files changed, 195 insertions, 9 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 56d6ec406..4fee0852f 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -19,6 +19,7 @@ import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
+import { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
@@ -128,13 +129,15 @@ export function AppInterface(props: { defaultUrl?: string }) {
component={(p) => (
<Show when={p.params.id ?? "new"}>
<TerminalProvider>
- <FileProvider>
- <PromptProvider>
- <Suspense fallback={<Loading />}>
- <Session />
- </Suspense>
- </PromptProvider>
- </FileProvider>
+ <FileProvider>
+ <PromptProvider>
+ <CommentsProvider>
+ <Suspense fallback={<Loading />}>
+ <Session />
+ </Suspense>
+ </CommentsProvider>
+ </PromptProvider>
+ </FileProvider>
</TerminalProvider>
</Show>
)}
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 5e936737a..b2c8cccca 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -30,6 +30,7 @@ import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
+import { useComments } from "@/context/comments"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -115,6 +116,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const files = useFile()
const prompt = usePrompt()
const layout = useLayout()
+ const comments = useComments()
const params = useParams()
const dialog = useDialog()
const providers = useProviders()
@@ -158,6 +160,7 @@ 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
@@ -1555,7 +1558,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{(item) => {
const preview = createMemo(() => selectionPreview(item.path, item.selection, item.preview))
return (
- <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
+ classList={{
+ "shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]": true,
+ "cursor-pointer hover:bg-surface-raised-base-hover": !!item.commentID,
+ }}
+ onClick={() => {
+ if (!item.commentID) return
+ comments.setFocus({ file: item.path, id: item.commentID })
+ view().reviewPanel.open()
+ tabs().open("review")
+ }}
+ >
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0">
@@ -1576,7 +1590,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon="close"
variant="ghost"
class="h-5 w-5"
- onClick={() => prompt.context.remove(item.key)}
+ onClick={(e) => {
+ e.stopPropagation()
+ prompt.context.remove(item.key)
+ }}
aria-label={language.t("prompt.context.removeFile")}
/>
</div>
diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx
new file mode 100644
index 000000000..12ee977e9
--- /dev/null
+++ b/packages/app/src/context/comments.tsx
@@ -0,0 +1,140 @@
+import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useParams } from "@solidjs/router"
+import { Persist, persisted } from "@/utils/persist"
+import type { SelectedLineRange } from "@/context/file"
+
+export type LineComment = {
+ id: string
+ file: string
+ selection: SelectedLineRange
+ comment: string
+ time: number
+}
+
+type CommentFocus = { file: string; id: string }
+
+const WORKSPACE_KEY = "__workspace__"
+const MAX_COMMENT_SESSIONS = 20
+
+type CommentSession = ReturnType<typeof createCommentSession>
+
+type CommentCacheEntry = {
+ value: CommentSession
+ dispose: VoidFunction
+}
+
+function createCommentSession(dir: string, id: string | undefined) {
+ const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
+
+ const [store, setStore, _, ready] = persisted(
+ Persist.scoped(dir, id, "comments", [legacy]),
+ createStore<{
+ comments: Record<string, LineComment[]>
+ }>({
+ comments: {},
+ }),
+ )
+
+ const [focus, setFocus] = createSignal<CommentFocus | null>(null)
+
+ const list = (file: string) => store.comments[file] ?? []
+
+ const add = (input: Omit<LineComment, "id" | "time">) => {
+ const next: LineComment = {
+ id: crypto.randomUUID(),
+ time: Date.now(),
+ ...input,
+ }
+
+ batch(() => {
+ setStore("comments", input.file, (items) => [...(items ?? []), next])
+ setFocus({ file: input.file, id: next.id })
+ })
+
+ return next
+ }
+
+ const remove = (file: string, id: string) => {
+ setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id))
+ setFocus((current) => (current?.id === id ? null : current))
+ }
+
+ const all = createMemo(() => {
+ const files = Object.keys(store.comments)
+ const items = files.flatMap((file) => store.comments[file] ?? [])
+ return items.slice().sort((a, b) => a.time - b.time)
+ })
+
+ return {
+ ready,
+ list,
+ all,
+ add,
+ remove,
+ focus: createMemo(() => focus()),
+ setFocus,
+ clearFocus: () => setFocus(null),
+ }
+}
+
+export const { use: useComments, provider: CommentsProvider } = createSimpleContext({
+ name: "Comments",
+ gate: false,
+ init: () => {
+ const params = useParams()
+ const cache = new Map<string, CommentCacheEntry>()
+
+ const disposeAll = () => {
+ for (const entry of cache.values()) {
+ entry.dispose()
+ }
+ cache.clear()
+ }
+
+ onCleanup(disposeAll)
+
+ const prune = () => {
+ while (cache.size > MAX_COMMENT_SESSIONS) {
+ const first = cache.keys().next().value
+ if (!first) return
+ const entry = cache.get(first)
+ entry?.dispose()
+ cache.delete(first)
+ }
+ }
+
+ const load = (dir: string, id: string | undefined) => {
+ const key = `${dir}:${id ?? WORKSPACE_KEY}`
+ const existing = cache.get(key)
+ if (existing) {
+ cache.delete(key)
+ cache.set(key, existing)
+ return existing.value
+ }
+
+ const entry = createRoot((dispose) => ({
+ value: createCommentSession(dir, id),
+ dispose,
+ }))
+
+ cache.set(key, entry)
+ prune()
+ return entry.value
+ }
+
+ const session = createMemo(() => load(params.dir!, params.id))
+
+ return {
+ ready: () => session().ready(),
+ list: (file: string) => session().list(file),
+ all: () => session().all(),
+ add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
+ remove: (file: string, id: string) => session().remove(file, id),
+ focus: () => session().focus(),
+ setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
+ clearFocus: () => session().clearFocus(),
+ }
+ },
+})
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index a76d9d5f1..40baa0ef5 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -43,6 +43,7 @@ export type FileContextItem = {
path: string
selection?: FileSelection
comment?: string
+ commentID?: string
preview?: string
}
@@ -139,6 +140,11 @@ function createPromptSession(dir: string, id: string | undefined) {
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
+
+ if (item.commentID) {
+ return `${key}:c=${item.commentID}`
+ }
+
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 1e0d7a89e..b2d9747c7 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -51,6 +51,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
+import { useComments, type LineComment } from "@/context/comments"
import { extractPromptFromParts } from "@/utils/prompt"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
@@ -82,6 +83,9 @@ interface SessionReviewTabProps {
onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
+ comments?: LineComment[]
+ focusedComment?: { file: string; id: string } | null
+ onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
classes?: {
root?: string
header?: string
@@ -168,6 +172,9 @@ function SessionReviewTab(props: SessionReviewTabProps) {
onViewFile={props.onViewFile}
readFile={readFile}
onLineComment={props.onLineComment}
+ comments={props.comments}
+ focusedComment={props.focusedComment}
+ onFocusedCommentChange={props.onFocusedCommentChange}
/>
)
}
@@ -187,6 +194,7 @@ export default function Page() {
const navigate = useNavigate()
const sdk = useSDK()
const prompt = usePrompt()
+ const comments = useComments()
const permission = usePermission()
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -513,11 +521,17 @@ export default function Page() {
}) => {
const selection = selectionFromLines(input.selection)
const preview = input.preview ?? selectionPreview(input.file, selection)
+ const saved = comments.add({
+ file: input.file,
+ selection: input.selection,
+ comment: input.comment,
+ })
prompt.context.add({
type: "file",
path: input.file,
selection,
comment: input.comment,
+ commentID: saved.id,
preview,
})
}
@@ -1433,6 +1447,9 @@ export default function Page() {
view={view}
diffStyle="unified"
onLineComment={addCommentToContext}
+ comments={comments.all()}
+ focusedComment={comments.focus()}
+ onFocusedCommentChange={comments.setFocus}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
@@ -1749,6 +1766,9 @@ export default function Page() {
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onLineComment={addCommentToContext}
+ comments={comments.all()}
+ focusedComment={comments.focus()}
+ onFocusedCommentChange={comments.setFocus}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)