Skip to content

Commit 5a85b36

Browse files
authored
Merge branch 'main' into posthog-code/mobile-plan-approval-model-swap
Generated-By: PostHog Code Task-Id: 25776734-2103-44a9-8491-bb67d9f0422a
2 parents dd04d38 + 09a3a2b commit 5a85b36

35 files changed

Lines changed: 1404 additions & 473 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, it } from "vitest";
2+
import { formatTokens } from "./spendAnalysisFormat";
3+
4+
describe("formatTokens", () => {
5+
it.each([
6+
[0, "0"],
7+
[999, "999"],
8+
[1_000, "1k"],
9+
[108_400, "108k"],
10+
[1_500_000, "1.5M"],
11+
[999_949_999, "999.9M"],
12+
[1_000_000_000, "1.0B"],
13+
[2_449_300_000, "2.4B"],
14+
])("formats %d as %s", (input, expected) => {
15+
expect(formatTokens(input)).toBe(expected);
16+
});
17+
});

packages/core/src/billing/spendAnalysisFormat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export function formatUsd(amount: number): string {
66
}
77

88
export function formatTokens(n: number): string {
9+
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
910
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
1011
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
1112
return n.toString();

packages/core/src/code-review/reviewPrompts.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ export function buildInlineCommentPrompt(
3838
return `In file <file path="${escapedPath}" />, ${lineRef} (${sideLabel}):\n\n${comment}`;
3939
}
4040

41+
/** File + line-range reference with no diff side label (not a review comment). */
42+
export function buildFileLineReferencePrompt(
43+
filePath: string,
44+
startLine: number,
45+
endLine: number,
46+
comment: string,
47+
): string {
48+
const lineRef = formatLineRef(startLine, endLine);
49+
const escapedPath = escapeXmlAttr(filePath);
50+
return `In file <file path="${escapedPath}" />, ${lineRef}:\n\n${comment}`;
51+
}
52+
4153
export function buildBatchedInlineCommentsPrompt(
4254
drafts: DraftComment[],
4355
): string {

packages/shared/src/analytics-events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export type CommandMenuAction =
5151
| "go-forward"
5252
| "open-task"
5353
| "open-channel"
54+
| "search-files"
55+
| "open-file"
5456
| "reload-window"
5557
| "show-log-folder";
5658

packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
selectFileSource,
77
} from "@posthog/core/code-editor/fileSource";
88
import { getRelativePath } from "@posthog/core/code-editor/pathUtils";
9+
import { buildFileLineReferencePrompt } from "@posthog/core/code-review/reviewPrompts";
10+
import { xmlToContent } from "@posthog/core/message-editor/content";
911
import {
1012
getImageMimeType,
1113
isRasterImageFile,
@@ -21,6 +23,7 @@ import { PanelMessage } from "../../../primitives/PanelMessage";
2123
import { SafeImagePreview } from "../../../primitives/SafeImagePreview";
2224
import { Tooltip } from "../../../primitives/Tooltip";
2325
import { openExternalUrl } from "../../../shell/openExternal";
26+
import { useDraftStore } from "../../message-editor/draftStore";
2427
import { usePanelLayoutStore } from "../../panels/panelLayoutStore";
2528
import { useFileTreeStore } from "../../right-sidebar/fileTreeStore";
2629
import { useCwd } from "../../sidebar/useCwd";
@@ -35,6 +38,10 @@ import {
3538
import { useFileEnrichment } from "../hooks/useFileEnrichment";
3639
import { CodeMirrorEditor } from "./CodeMirrorEditor";
3740
import { EnrichmentPopover } from "./EnrichmentPopover";
41+
import {
42+
SelectionCommentOverlay,
43+
useSelectionComposer,
44+
} from "./SelectionCommentOverlay";
3845

3946
interface CodeEditorPanelProps {
4047
taskId: string;
@@ -114,6 +121,23 @@ export function CodeEditorPanel({
114121
const expandToFile = useFileTreeStore((s) => s.expandToFile);
115122
const [copied, setCopied] = useState(false);
116123

124+
const composer = useSelectionComposer();
125+
const handleAddSelectionToChat = useCallback(
126+
(startLine: number, endLine: number, text: string) => {
127+
const prompt = buildFileLineReferencePrompt(
128+
absolutePath,
129+
startLine,
130+
endLine,
131+
text,
132+
);
133+
// sessionId === taskId for the in-task chat composer.
134+
useDraftStore
135+
.getState()
136+
.actions.insertPendingContent(taskId, xmlToContent(prompt));
137+
},
138+
[absolutePath, taskId],
139+
);
140+
117141
const handleMarkdownLinkClick = useCallback(
118142
(e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
119143
e.preventDefault();
@@ -267,8 +291,17 @@ export function CodeEditorPanel({
267291
relativePath={filePath}
268292
readOnly
269293
enrichment={enrichment}
294+
highlightSelectedLines
295+
onSelectionChange={composer.onSelectionChange}
270296
/>
271297
<EnrichmentPopover />
298+
<SelectionCommentOverlay
299+
selection={composer.selection}
300+
open={composer.open}
301+
filePath={filePath}
302+
onSubmit={handleAddSelectionToChat}
303+
onDismiss={composer.close}
304+
/>
272305
</Box>
273306
);
274307

packages/ui/src/features/code-editor/components/CodeMirrorEditor.tsx

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,60 @@
11
import { openSearchPanel } from "@codemirror/search";
2-
import { EditorView } from "@codemirror/view";
2+
import { RangeSetBuilder, StateField } from "@codemirror/state";
3+
import { Decoration, type DecorationSet, EditorView } from "@codemirror/view";
34
import type { SerializedEnrichment } from "@posthog/shared";
45
import { Box, Flex, Text } from "@radix-ui/themes";
5-
import { useEffect, useMemo } from "react";
6+
import { useEffect, useMemo, useRef } from "react";
67
import { setEnrichmentEffect } from "../extensions/postHogEnrichment";
78
import { useCodeMirror } from "../hooks/useCodeMirror";
89
import { useEditorExtensions } from "../hooks/useEditorExtensions";
910
import { usePendingScrollStore } from "../pendingScrollStore";
1011

12+
const selectedLineDecoration = Decoration.line({ class: "cm-selected-lines" });
13+
const selectedLinesField = StateField.define<DecorationSet>({
14+
create: () => Decoration.none,
15+
update(_value, tr) {
16+
const sel = tr.state.selection.main;
17+
if (sel.empty) return Decoration.none;
18+
const builder = new RangeSetBuilder<Decoration>();
19+
const from = tr.state.doc.lineAt(sel.from).number;
20+
const to = tr.state.doc.lineAt(sel.to).number;
21+
for (let n = from; n <= to; n++) {
22+
const pos = tr.state.doc.line(n).from;
23+
builder.add(pos, pos, selectedLineDecoration);
24+
}
25+
return builder.finish();
26+
},
27+
provide: (f) => EditorView.decorations.from(f),
28+
});
29+
const selectedLinesTheme = EditorView.theme({
30+
".cm-selected-lines": {
31+
backgroundColor: "var(--accent-a3)",
32+
boxShadow: "inset 2px 0 0 0 var(--accent-8)",
33+
},
34+
"& .cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
35+
background: "transparent !important",
36+
},
37+
});
38+
39+
export interface EditorSelection {
40+
text: string;
41+
/** 1-based line numbers. */
42+
fromLine: number;
43+
toLine: number;
44+
/** Viewport pixel anchor below the selection, or null when off-screen. */
45+
anchor: { top: number; left: number } | null;
46+
}
47+
1148
interface CodeMirrorEditorProps {
1249
content: string;
1350
filePath?: string;
1451
relativePath?: string;
1552
readOnly?: boolean;
1653
enrichment?: SerializedEnrichment | null;
54+
/** Fires on every selection (or doc) change with the current selection. */
55+
onSelectionChange?: (selection: EditorSelection) => void;
56+
/** Highlight the active selection as full lines (code-review style). */
57+
highlightSelectedLines?: boolean;
1758
}
1859

1960
export function CodeMirrorEditor({
@@ -22,9 +63,66 @@ export function CodeMirrorEditor({
2263
relativePath,
2364
readOnly = false,
2465
enrichment,
66+
onSelectionChange,
67+
highlightSelectedLines = false,
2568
}: CodeMirrorEditorProps) {
2669
const enrichmentEnabled = enrichment !== undefined;
27-
const extensions = useEditorExtensions(filePath, readOnly, enrichmentEnabled);
70+
const baseExtensions = useEditorExtensions(
71+
filePath,
72+
readOnly,
73+
enrichmentEnabled,
74+
);
75+
76+
// Ref-stable listener: a changing extension would tear down the editor.
77+
const onSelectionChangeRef = useRef(onSelectionChange);
78+
onSelectionChangeRef.current = onSelectionChange;
79+
const selectionExtension = useMemo(
80+
() =>
81+
EditorView.updateListener.of((update) => {
82+
const cb = onSelectionChangeRef.current;
83+
if (!cb) return;
84+
const changed = update.selectionSet || update.docChanged;
85+
// Also refire on scroll/resize so the anchor tracks the selection.
86+
const moved = update.viewportChanged || update.geometryChanged;
87+
if (!changed && !moved) return;
88+
const sel = update.state.selection.main;
89+
const doc = update.state.doc;
90+
if (sel.empty) {
91+
// No coords for an empty caret — just notify so consumers can hide.
92+
if (changed) {
93+
cb({
94+
text: "",
95+
fromLine: doc.lineAt(sel.from).number,
96+
toLine: doc.lineAt(sel.to).number,
97+
anchor: null,
98+
});
99+
}
100+
return;
101+
}
102+
const endRect = update.view.coordsAtPos(sel.to);
103+
const startRect = update.view.coordsAtPos(doc.lineAt(sel.to).from);
104+
cb({
105+
text: doc.sliceString(sel.from, sel.to),
106+
fromLine: doc.lineAt(sel.from).number,
107+
toLine: doc.lineAt(sel.to).number,
108+
anchor: endRect
109+
? { top: endRect.bottom, left: (startRect ?? endRect).left }
110+
: null,
111+
});
112+
}),
113+
[],
114+
);
115+
const extensions = useMemo(
116+
() => [
117+
...baseExtensions,
118+
selectionExtension,
119+
...(highlightSelectedLines
120+
? [selectedLinesField, selectedLinesTheme]
121+
: []),
122+
],
123+
[baseExtensions, selectionExtension, highlightSelectedLines],
124+
);
125+
28126
const options = useMemo(
29127
() => ({ doc: content, extensions, filePath }),
30128
[content, extensions, filePath],
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Plus } from "@phosphor-icons/react";
2+
import { Button } from "@posthog/quill";
3+
import type { EditorSelection } from "@posthog/ui/features/code-editor/components/CodeMirrorEditor";
4+
import { CommentAnnotation } from "@posthog/ui/features/code-review/components/CommentAnnotation";
5+
import { Tooltip } from "@radix-ui/themes";
6+
import { useCallback, useState } from "react";
7+
import { createPortal } from "react-dom";
8+
9+
/** Selection state for the "select lines → add to chat" overlay. */
10+
export function useSelectionComposer() {
11+
const [selection, setSelection] = useState<EditorSelection | null>(null);
12+
const [open, setOpen] = useState(false);
13+
const onSelectionChange = useCallback((next: EditorSelection) => {
14+
setSelection(next);
15+
setOpen(next.text.trim().length > 0);
16+
}, []);
17+
const close = useCallback(() => setOpen(false), []);
18+
return { selection, open, onSelectionChange, close };
19+
}
20+
21+
interface SelectionCommentOverlayProps {
22+
selection: EditorSelection | null;
23+
open: boolean;
24+
filePath: string;
25+
onSubmit: (startLine: number, endLine: number, text: string) => void;
26+
onDismiss: () => void;
27+
}
28+
29+
/**
30+
* Selecting lines shows an explicit "+" button (like the code-review gutter);
31+
* clicking it opens the `CommentAnnotation` composer. Shared by the new-task
32+
* preview and the in-task editor.
33+
*/
34+
export function SelectionCommentOverlay({
35+
selection,
36+
open,
37+
filePath,
38+
onSubmit,
39+
onDismiss,
40+
}: SelectionCommentOverlayProps) {
41+
if (!open || !selection?.anchor) return null;
42+
// Key by the range so a fresh selection remounts the card back to the "+".
43+
return (
44+
<SelectionComposerCard
45+
key={`${selection.fromLine}:${selection.toLine}`}
46+
anchor={selection.anchor}
47+
fromLine={selection.fromLine}
48+
toLine={selection.toLine}
49+
filePath={filePath}
50+
onSubmit={onSubmit}
51+
onDismiss={onDismiss}
52+
/>
53+
);
54+
}
55+
56+
function SelectionComposerCard({
57+
anchor,
58+
fromLine,
59+
toLine,
60+
filePath,
61+
onSubmit,
62+
onDismiss,
63+
}: {
64+
anchor: { top: number; left: number };
65+
fromLine: number;
66+
toLine: number;
67+
filePath: string;
68+
onSubmit: (startLine: number, endLine: number, text: string) => void;
69+
onDismiss: () => void;
70+
}) {
71+
const [expanded, setExpanded] = useState(false);
72+
const style = { top: anchor.top + 4, left: anchor.left };
73+
74+
if (!expanded) {
75+
return createPortal(
76+
<Tooltip content="Add to chat">
77+
<Button
78+
type="button"
79+
variant="primary"
80+
size="icon-sm"
81+
aria-label="Add selection to chat"
82+
className="fixed z-50 shadow-sm"
83+
style={style}
84+
onClick={() => setExpanded(true)}
85+
>
86+
<Plus size={14} weight="bold" />
87+
</Button>
88+
</Tooltip>,
89+
document.body,
90+
);
91+
}
92+
93+
return createPortal(
94+
<div
95+
className="fixed z-50 w-[420px] max-w-[80vw] rounded-md border border-gray-5 bg-gray-2 shadow-lg"
96+
style={style}
97+
>
98+
<CommentAnnotation
99+
filePath={filePath}
100+
startLine={fromLine}
101+
endLine={toLine}
102+
onDismiss={onDismiss}
103+
onSubmitText={(text) => onSubmit(fromLine, toLine, text)}
104+
/>
105+
</div>,
106+
document.body,
107+
);
108+
}

0 commit comments

Comments
 (0)