Merged
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
16 changes: 13 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
return normalizedItems;
}

// Renderer positions arrive in CSS pixels; popup() expects window points, so
// page zoom must be factored in or menus drift proportionally to their
// distance from the window origin.
const normalizePosition = (
position: Option.Option<ElectronMenuPosition>,
zoomFactor: number,
): Option.Option<ElectronMenuPosition> =>
Option.filter(
position,
({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
({ x, y }) =>
Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
).pipe(
Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
);

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
Expand DownExpand Up@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {

try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupPosition = normalizePosition(
input.position,
input.window.webContents.getZoomFactor(),
);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ import {
KEY_TAB_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_BACKSPACE_COMMAND,
PASTE_COMMAND,
$getRoot,
HISTORY_MERGE_TAG,
DecoratorNode,
Expand DownExpand Up@@ -55,6 +56,7 @@ import {
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
selectionTouchesMentionBoundary,
splitPromptIntoComposerSegments,
Expand DownExpand Up@@ -1117,6 +1119,80 @@ function ComposerInlineTokenBackspacePlugin() {
return null;
}

function ComposerInlineTokenPastePlugin() {
const [editor] = useLexicalComposerContext();

useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
return false;
}
if (event.clipboardData.files.length > 0) {
return false;
}
const text = event.clipboardData.getData("text/plain");
if (text.length === 0) {
return false;
}
// Token grammar requires trailing whitespace; a virtual newline lets a
// mention at the very end of the pasted text still parse.
const mentions = collectComposerInlineTokens(`${text}\n`).filter(
(token) => token.type === "mention" && token.end <= text.length,
);
if (mentions.length === 0) {
return false;
}
event.preventDefault();
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const nodes: LexicalNode[] = [];
const appendText = (value: string) => {
const lines = value.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
if (line.length > 0) {
nodes.push($createTextNode(line));
}
if (index < lines.length - 1) {
nodes.push($createLineBreakNode());
}
}
};
let cursor = 0;
for (const mention of mentions) {
if (mention.start < cursor) {
continue;
}
if (mention.start > cursor) {
appendText(text.slice(cursor, mention.start));
}
nodes.push($createComposerMentionNode(mention.value));
cursor = mention.end;
}
if (cursor < text.length) {
appendText(text.slice(cursor));
} else {
// Keep the serialized prompt valid: mention tokens need trailing
// whitespace, so a paste ending in a mention gets the same
// trailing space the autocomplete inserts.
nodes.push($createTextNode(" "));
}
selection.insertNodes(nodes);
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor]);

return null;
}

function ComposerSurroundSelectionPlugin(props: {
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
Expand DownExpand Up@@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
import type {
ContextMenuItem as TreeContextMenuItem,
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";

import { toastManager } from "~/components/ui/toast";
import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";

import { useProjectEntriesQuery } from "./projectFilesQueryState";
Expand DownExpand Up@@ -39,6 +48,7 @@ export default function FileBrowserPanel({
onOpenFile,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
const entries = entriesQuery.data?.entries ?? [];
const entryKinds = useMemo(
Expand All@@ -49,7 +59,84 @@ export default function FileBrowserPanel({
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const previousTreePathsRef = useRef<readonly string[]>([]);

// The tree renders rows in shadow DOM and its anchor rect is unreliable, so
// capture the right-click position ourselves; contextmenu is a composed
// event, so a capture-phase listener sees it with viewport coordinates.
const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
useEffect(() => {
const capturePointer = (event: MouseEvent) => {
contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
};
document.addEventListener("contextmenu", capturePointer, true);
return () => document.removeEventListener("contextmenu", capturePointer, true);
}, []);

const showEntryContextMenu = async (
item: TreeContextMenuItem,
context: TreeContextMenuOpenContext,
) => {
const api = readLocalApi();
if (!api) {
context.close();
return;
}
const relativePath = item.path.replace(/\/$/, "");
const mention = serializeComposerFileLink(relativePath);
const pointer = contextMenuPointerRef.current;
const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
const anchorRect = context.anchorElement.getBoundingClientRect();
const position = pointerIsFresh
? { x: pointer.x, y: pointer.y }
: { x: anchorRect.left, y: anchorRect.bottom };
try {
const clicked = await api.contextMenu.show(
[
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
],
position,
);
if (clicked === "copy-mention") {
try {
await writeTextToClipboard(mention);
toastManager.add({ type: "success", title: "Mention copied", description: relativePath });
} catch (error) {
toastManager.add({
type: "error",
title: "Failed to copy mention",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
return;
}
if (clicked === "add-to-chat") {
const inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false;
if (!inserted) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "Open a chat for this project and try again.",
});
}
}
} finally {
context.close();
}
};
const showEntryContextMenuRef = useRef(showEntryContextMenu);
useEffect(() => {
showEntryContextMenuRef.current = showEntryContextMenu;
});

const { model } = useFileTree({
composition: {
contextMenu: {
triggerMode: "right-click",
onOpen: (item, context) => {
void showEntryContextMenuRef.current(item, context);
},
},
},
density: "compact",
fileTreeSearchMode: "hide-non-matches",
flattenEmptyDirectories: true,
Expand Down
29 changes: 29 additions & 0 deletions docs/fork/0001-file-explorer-mention-actions.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
# 0001: File explorer mention actions and zoom-aware context menus

- PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1)
- Status: active

## What you can do now

- Right-click a file or directory in the Files panel and pick **Add to chat**
to drop it into the active composer as a file pill, or **Copy mention** to
put it on the clipboard and paste it wherever it belongs in a prompt.
- Pasting mention text into the composer renders the file pill. This works
for any pasted mention, including ones copied out of an earlier message,
not just ones coming from the file explorer.
- Context menus across the desktop app open at the cursor even when the
window is zoomed. Before this, any zoom level made menus drift away from
the click, worst near the right edge of the window.

## Why

Attaching files to a prompt required typing @-mentions by hand even though
the file explorer already has the file in front of you. Browsing and
prompting should be one flow.

## Upstream considerations

The zoomed context menu drift is a genuine upstream bug and that part is the
strongest candidate to submit once upstream opens contributions. The mention
actions are a feature upstream may or may not want; they depend only on
public composer and tree APIs, so the rebase burden is low.
34 changes: 34 additions & 0 deletions docs/fork/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
# Fork divergence ledger

This directory tracks everything this fork carries that is not in upstream
[pingdotgg/t3code](https://github.com/pingdotgg/t3code). One numbered entry per
divergence, newest last. When upstream ships an equivalent, update the status
here and note what replaced our version instead of deleting the entry, so the
history of what we carried and why stays reconstructable.

## Writing an entry

Entries are product focused. Describe what someone can do now and why we
wanted it, not how it was built. No touched-file lists, component names, or
implementation details; the linked PR already carries all of that, and code
detail in the ledger goes stale the moment the code moves.

Each entry uses these sections:

- **What you can do now**: the user-visible capabilities, as bullets.
- **Why**: the product rationale for carrying the divergence.
- **Upstream considerations**: whether and how we would submit it upstream,
and anything that affects the rebase burden.

## Statuses

- `active`: carried by this fork, not in upstream.
- `submitted`: proposed to upstream, waiting on the outcome.
- `upstreamed`: merged into upstream; our patch was dropped or reconciled.
- `superseded`: upstream shipped a different solution; our patch was dropped.

## Ledger

| # | Divergence | PR | Status |
| ---- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
| 0001 | [File explorer mention actions and zoom-aware context menus](./0001-file-explorer-mention-actions.md) | [#1](https://github.com/TrogonStack/t3code/pull/1) | active |
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
16 changes: 13 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
return normalizedItems;
}

// Renderer positions arrive in CSS pixels; popup() expects window points, so
// page zoom must be factored in or menus drift proportionally to their
// distance from the window origin.
const normalizePosition = (
position: Option.Option<ElectronMenuPosition>,
zoomFactor: number,
): Option.Option<ElectronMenuPosition> =>
Option.filter(
position,
({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
({ x, y }) =>
Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
).pipe(
Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
);

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
Expand DownExpand Up@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {

try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupPosition = normalizePosition(
input.position,
input.window.webContents.getZoomFactor(),
);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ import {
KEY_TAB_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_BACKSPACE_COMMAND,
PASTE_COMMAND,
$getRoot,
HISTORY_MERGE_TAG,
DecoratorNode,
Expand DownExpand Up@@ -55,6 +56,7 @@ import {
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
selectionTouchesMentionBoundary,
splitPromptIntoComposerSegments,
Expand DownExpand Up@@ -1117,6 +1119,80 @@ function ComposerInlineTokenBackspacePlugin() {
return null;
}

function ComposerInlineTokenPastePlugin() {
const [editor] = useLexicalComposerContext();

useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
return false;
}
if (event.clipboardData.files.length > 0) {
return false;
}
const text = event.clipboardData.getData("text/plain");
if (text.length === 0) {
return false;
}
// Token grammar requires trailing whitespace; a virtual newline lets a
// mention at the very end of the pasted text still parse.
const mentions = collectComposerInlineTokens(`${text}\n`).filter(
(token) => token.type === "mention" && token.end <= text.length,
);
if (mentions.length === 0) {
return false;
}
event.preventDefault();
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const nodes: LexicalNode[] = [];
const appendText = (value: string) => {
const lines = value.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
if (line.length > 0) {
nodes.push($createTextNode(line));
}
if (index < lines.length - 1) {
nodes.push($createLineBreakNode());
}
}
};
let cursor = 0;
for (const mention of mentions) {
if (mention.start < cursor) {
continue;
}
if (mention.start > cursor) {
appendText(text.slice(cursor, mention.start));
}
nodes.push($createComposerMentionNode(mention.value));
cursor = mention.end;
}
if (cursor < text.length) {
appendText(text.slice(cursor));
} else {
// Keep the serialized prompt valid: mention tokens need trailing
// whitespace, so a paste ending in a mention gets the same
// trailing space the autocomplete inserts.
nodes.push($createTextNode(" "));
}
selection.insertNodes(nodes);
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor]);

return null;
}

function ComposerSurroundSelectionPlugin(props: {
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
Expand DownExpand Up@@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
import type {
ContextMenuItem as TreeContextMenuItem,
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";

import { toastManager } from "~/components/ui/toast";
import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";

import { useProjectEntriesQuery } from "./projectFilesQueryState";
Expand DownExpand Up@@ -39,6 +48,7 @@ export default function FileBrowserPanel({
onOpenFile,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
const entries = entriesQuery.data?.entries ?? [];
const entryKinds = useMemo(
Expand All@@ -49,7 +59,84 @@ export default function FileBrowserPanel({
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const previousTreePathsRef = useRef<readonly string[]>([]);

// The tree renders rows in shadow DOM and its anchor rect is unreliable, so
// capture the right-click position ourselves; contextmenu is a composed
// event, so a capture-phase listener sees it with viewport coordinates.
const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
useEffect(() => {
const capturePointer = (event: MouseEvent) => {
contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
};
document.addEventListener("contextmenu", capturePointer, true);
return () => document.removeEventListener("contextmenu", capturePointer, true);
}, []);

const showEntryContextMenu = async (
item: TreeContextMenuItem,
context: TreeContextMenuOpenContext,
) => {
const api = readLocalApi();
if (!api) {
context.close();
return;
}
const relativePath = item.path.replace(/\/$/, "");
const mention = serializeComposerFileLink(relativePath);
const pointer = contextMenuPointerRef.current;
const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
const anchorRect = context.anchorElement.getBoundingClientRect();
const position = pointerIsFresh
? { x: pointer.x, y: pointer.y }
: { x: anchorRect.left, y: anchorRect.bottom };
try {
const clicked = await api.contextMenu.show(
[
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
],
position,
);
if (clicked === "copy-mention") {
try {
await writeTextToClipboard(mention);
toastManager.add({ type: "success", title: "Mention copied", description: relativePath });
} catch (error) {
toastManager.add({
type: "error",
title: "Failed to copy mention",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
return;
}
if (clicked === "add-to-chat") {
const inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false;
if (!inserted) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "Open a chat for this project and try again.",
});
}
}
} finally {
context.close();
}
};
const showEntryContextMenuRef = useRef(showEntryContextMenu);
useEffect(() => {
showEntryContextMenuRef.current = showEntryContextMenu;
});

const { model } = useFileTree({
composition: {
contextMenu: {
triggerMode: "right-click",
onOpen: (item, context) => {
void showEntryContextMenuRef.current(item, context);
},
},
},
density: "compact",
fileTreeSearchMode: "hide-non-matches",
flattenEmptyDirectories: true,
Expand Down
29 changes: 29 additions & 0 deletions docs/fork/0001-file-explorer-mention-actions.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
# 0001: File explorer mention actions and zoom-aware context menus

- PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1)
- Status: active

## What you can do now

- Right-click a file or directory in the Files panel and pick **Add to chat**
to drop it into the active composer as a file pill, or **Copy mention** to
put it on the clipboard and paste it wherever it belongs in a prompt.
- Pasting mention text into the composer renders the file pill. This works
for any pasted mention, including ones copied out of an earlier message,
not just ones coming from the file explorer.
- Context menus across the desktop app open at the cursor even when the
window is zoomed. Before this, any zoom level made menus drift away from
the click, worst near the right edge of the window.

## Why

Attaching files to a prompt required typing @-mentions by hand even though
the file explorer already has the file in front of you. Browsing and
prompting should be one flow.

## Upstream considerations

The zoomed context menu drift is a genuine upstream bug and that part is the
strongest candidate to submit once upstream opens contributions. The mention
actions are a feature upstream may or may not want; they depend only on
public composer and tree APIs, so the rebase burden is low.
34 changes: 34 additions & 0 deletions docs/fork/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
# Fork divergence ledger

This directory tracks everything this fork carries that is not in upstream
[pingdotgg/t3code](https://github.com/pingdotgg/t3code). One numbered entry per
divergence, newest last. When upstream ships an equivalent, update the status
here and note what replaced our version instead of deleting the entry, so the
history of what we carried and why stays reconstructable.

## Writing an entry

Entries are product focused. Describe what someone can do now and why we
wanted it, not how it was built. No touched-file lists, component names, or
implementation details; the linked PR already carries all of that, and code
detail in the ledger goes stale the moment the code moves.

Each entry uses these sections:

- **What you can do now**: the user-visible capabilities, as bullets.
- **Why**: the product rationale for carrying the divergence.
- **Upstream considerations**: whether and how we would submit it upstream,
and anything that affects the rebase burden.

## Statuses

- `active`: carried by this fork, not in upstream.
- `submitted`: proposed to upstream, waiting on the outcome.
- `upstreamed`: merged into upstream; our patch was dropped or reconciled.
- `superseded`: upstream shipped a different solution; our patch was dropped.

## Ledger

| # | Divergence | PR | Status |
| ---- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
| 0001 | [File explorer mention actions and zoom-aware context menus](./0001-file-explorer-mention-actions.md) | [#1](https://github.com/TrogonStack/t3code/pull/1) | active |
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
16 changes: 13 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
return normalizedItems;
}

// Renderer positions arrive in CSS pixels; popup() expects window points, so
// page zoom must be factored in or menus drift proportionally to their
// distance from the window origin.
const normalizePosition = (
position: Option.Option<ElectronMenuPosition>,
zoomFactor: number,
): Option.Option<ElectronMenuPosition> =>
Option.filter(
position,
({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
({ x, y }) =>
Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
).pipe(
Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
);

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
Expand DownExpand Up@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {

try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupPosition = normalizePosition(
input.position,
input.window.webContents.getZoomFactor(),
);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ import {
KEY_TAB_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_BACKSPACE_COMMAND,
PASTE_COMMAND,
$getRoot,
HISTORY_MERGE_TAG,
DecoratorNode,
Expand DownExpand Up@@ -55,6 +56,7 @@ import {
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
selectionTouchesMentionBoundary,
splitPromptIntoComposerSegments,
Expand DownExpand Up@@ -1117,6 +1119,80 @@ function ComposerInlineTokenBackspacePlugin() {
return null;
}

function ComposerInlineTokenPastePlugin() {
const [editor] = useLexicalComposerContext();

useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
return false;
}
if (event.clipboardData.files.length > 0) {
return false;
}
const text = event.clipboardData.getData("text/plain");
if (text.length === 0) {
return false;
}
// Token grammar requires trailing whitespace; a virtual newline lets a
// mention at the very end of the pasted text still parse.
const mentions = collectComposerInlineTokens(`${text}\n`).filter(
(token) => token.type === "mention" && token.end <= text.length,
);
if (mentions.length === 0) {
return false;
}
event.preventDefault();
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const nodes: LexicalNode[] = [];
const appendText = (value: string) => {
const lines = value.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
if (line.length > 0) {
nodes.push($createTextNode(line));
}
if (index < lines.length - 1) {
nodes.push($createLineBreakNode());
}
}
};
let cursor = 0;
for (const mention of mentions) {
if (mention.start < cursor) {
continue;
}
if (mention.start > cursor) {
appendText(text.slice(cursor, mention.start));
}
nodes.push($createComposerMentionNode(mention.value));
cursor = mention.end;
}
if (cursor < text.length) {
appendText(text.slice(cursor));
} else {
// Keep the serialized prompt valid: mention tokens need trailing
// whitespace, so a paste ending in a mention gets the same
// trailing space the autocomplete inserts.
nodes.push($createTextNode(" "));
}
selection.insertNodes(nodes);
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor]);

return null;
}

function ComposerSurroundSelectionPlugin(props: {
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
Expand DownExpand Up@@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
import type {
ContextMenuItem as TreeContextMenuItem,
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";

import { toastManager } from "~/components/ui/toast";
import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";

import { useProjectEntriesQuery } from "./projectFilesQueryState";
Expand DownExpand Up@@ -39,6 +48,7 @@ export default function FileBrowserPanel({
onOpenFile,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
const entries = entriesQuery.data?.entries ?? [];
const entryKinds = useMemo(
Expand All@@ -49,7 +59,84 @@ export default function FileBrowserPanel({
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const previousTreePathsRef = useRef<readonly string[]>([]);

// The tree renders rows in shadow DOM and its anchor rect is unreliable, so
// capture the right-click position ourselves; contextmenu is a composed
// event, so a capture-phase listener sees it with viewport coordinates.
const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
useEffect(() => {
const capturePointer = (event: MouseEvent) => {
contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
};
document.addEventListener("contextmenu", capturePointer, true);
return () => document.removeEventListener("contextmenu", capturePointer, true);
}, []);

const showEntryContextMenu = async (
item: TreeContextMenuItem,
context: TreeContextMenuOpenContext,
) => {
const api = readLocalApi();
if (!api) {
context.close();
return;
}
const relativePath = item.path.replace(/\/$/, "");
const mention = serializeComposerFileLink(relativePath);
const pointer = contextMenuPointerRef.current;
const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
const anchorRect = context.anchorElement.getBoundingClientRect();
const position = pointerIsFresh
? { x: pointer.x, y: pointer.y }
: { x: anchorRect.left, y: anchorRect.bottom };
try {
const clicked = await api.contextMenu.show(
[
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
],
position,
);
if (clicked === "copy-mention") {
try {
await writeTextToClipboard(mention);
toastManager.add({ type: "success", title: "Mention copied", description: relativePath });
} catch (error) {
toastManager.add({
type: "error",
title: "Failed to copy mention",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
return;
}
if (clicked === "add-to-chat") {
const inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false;
if (!inserted) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "Open a chat for this project and try again.",
});
}
}
} finally {
context.close();
}
};
const showEntryContextMenuRef = useRef(showEntryContextMenu);
useEffect(() => {
showEntryContextMenuRef.current = showEntryContextMenu;
});

const { model } = useFileTree({
composition: {
contextMenu: {
triggerMode: "right-click",
onOpen: (item, context) => {
void showEntryContextMenuRef.current(item, context);
},
},
},
density: "compact",
fileTreeSearchMode: "hide-non-matches",
flattenEmptyDirectories: true,
Expand Down
29 changes: 29 additions & 0 deletions docs/fork/0001-file-explorer-mention-actions.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
# 0001: File explorer mention actions and zoom-aware context menus

- PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1)
- Status: active

## What you can do now

- Right-click a file or directory in the Files panel and pick **Add to chat**
to drop it into the active composer as a file pill, or **Copy mention** to
put it on the clipboard and paste it wherever it belongs in a prompt.
- Pasting mention text into the composer renders the file pill. This works
for any pasted mention, including ones copied out of an earlier message,
not just ones coming from the file explorer.
- Context menus across the desktop app open at the cursor even when the
window is zoomed. Before this, any zoom level made menus drift away from
the click, worst near the right edge of the window.

## Why

Attaching files to a prompt required typing @-mentions by hand even though
the file explorer already has the file in front of you. Browsing and
prompting should be one flow.

## Upstream considerations

The zoomed context menu drift is a genuine upstream bug and that part is the
strongest candidate to submit once upstream opens contributions. The mention
actions are a feature upstream may or may not want; they depend only on
public composer and tree APIs, so the rebase burden is low.
34 changes: 34 additions & 0 deletions docs/fork/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
# Fork divergence ledger

This directory tracks everything this fork carries that is not in upstream
[pingdotgg/t3code](https://github.com/pingdotgg/t3code). One numbered entry per
divergence, newest last. When upstream ships an equivalent, update the status
here and note what replaced our version instead of deleting the entry, so the
history of what we carried and why stays reconstructable.

## Writing an entry

Entries are product focused. Describe what someone can do now and why we
wanted it, not how it was built. No touched-file lists, component names, or
implementation details; the linked PR already carries all of that, and code
detail in the ledger goes stale the moment the code moves.

Each entry uses these sections:

- **What you can do now**: the user-visible capabilities, as bullets.
- **Why**: the product rationale for carrying the divergence.
- **Upstream considerations**: whether and how we would submit it upstream,
and anything that affects the rebase burden.

## Statuses

- `active`: carried by this fork, not in upstream.
- `submitted`: proposed to upstream, waiting on the outcome.
- `upstreamed`: merged into upstream; our patch was dropped or reconciled.
- `superseded`: upstream shipped a different solution; our patch was dropped.

## Ledger

| # | Divergence | PR | Status |
| ---- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
| 0001 | [File explorer mention actions and zoom-aware context menus](./0001-file-explorer-mention-actions.md) | [#1](https://github.com/TrogonStack/t3code/pull/1) | active |
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
16 changes: 13 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
return normalizedItems;
}

// Renderer positions arrive in CSS pixels; popup() expects window points, so
// page zoom must be factored in or menus drift proportionally to their
// distance from the window origin.
const normalizePosition = (
position: Option.Option<ElectronMenuPosition>,
zoomFactor: number,
): Option.Option<ElectronMenuPosition> =>
Option.filter(
position,
({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
({ x, y }) =>
Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
).pipe(
Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
);

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
Expand DownExpand Up@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {

try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupPosition = normalizePosition(
input.position,
input.window.webContents.getZoomFactor(),
);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ import {
KEY_TAB_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_BACKSPACE_COMMAND,
PASTE_COMMAND,
$getRoot,
HISTORY_MERGE_TAG,
DecoratorNode,
Expand DownExpand Up@@ -55,6 +56,7 @@ import {
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
selectionTouchesMentionBoundary,
splitPromptIntoComposerSegments,
Expand DownExpand Up@@ -1117,6 +1119,80 @@ function ComposerInlineTokenBackspacePlugin() {
return null;
}

function ComposerInlineTokenPastePlugin() {
const [editor] = useLexicalComposerContext();

useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
return false;
}
if (event.clipboardData.files.length > 0) {
return false;
}
const text = event.clipboardData.getData("text/plain");
if (text.length === 0) {
return false;
}
// Token grammar requires trailing whitespace; a virtual newline lets a
// mention at the very end of the pasted text still parse.
const mentions = collectComposerInlineTokens(`${text}\n`).filter(
(token) => token.type === "mention" && token.end <= text.length,
);
if (mentions.length === 0) {
return false;
}
event.preventDefault();
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const nodes: LexicalNode[] = [];
const appendText = (value: string) => {
const lines = value.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
if (line.length > 0) {
nodes.push($createTextNode(line));
}
if (index < lines.length - 1) {
nodes.push($createLineBreakNode());
}
}
};
let cursor = 0;
for (const mention of mentions) {
if (mention.start < cursor) {
continue;
}
if (mention.start > cursor) {
appendText(text.slice(cursor, mention.start));
}
nodes.push($createComposerMentionNode(mention.value));
cursor = mention.end;
}
if (cursor < text.length) {
appendText(text.slice(cursor));
} else {
// Keep the serialized prompt valid: mention tokens need trailing
// whitespace, so a paste ending in a mention gets the same
// trailing space the autocomplete inserts.
nodes.push($createTextNode(" "));
}
selection.insertNodes(nodes);
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor]);

return null;
}

function ComposerSurroundSelectionPlugin(props: {
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
Expand DownExpand Up@@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
import type {
ContextMenuItem as TreeContextMenuItem,
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";

import { toastManager } from "~/components/ui/toast";
import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";

import { useProjectEntriesQuery } from "./projectFilesQueryState";
Expand DownExpand Up@@ -39,6 +48,7 @@ export default function FileBrowserPanel({
onOpenFile,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
const entries = entriesQuery.data?.entries ?? [];
const entryKinds = useMemo(
Expand All@@ -49,7 +59,84 @@ export default function FileBrowserPanel({
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const previousTreePathsRef = useRef<readonly string[]>([]);

// The tree renders rows in shadow DOM and its anchor rect is unreliable, so
// capture the right-click position ourselves; contextmenu is a composed
// event, so a capture-phase listener sees it with viewport coordinates.
const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
useEffect(() => {
const capturePointer = (event: MouseEvent) => {
contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
};
document.addEventListener("contextmenu", capturePointer, true);
return () => document.removeEventListener("contextmenu", capturePointer, true);
}, []);

const showEntryContextMenu = async (
item: TreeContextMenuItem,
context: TreeContextMenuOpenContext,
) => {
const api = readLocalApi();
if (!api) {
context.close();
return;
}
const relativePath = item.path.replace(/\/$/, "");
const mention = serializeComposerFileLink(relativePath);
const pointer = contextMenuPointerRef.current;
const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
const anchorRect = context.anchorElement.getBoundingClientRect();
const position = pointerIsFresh
? { x: pointer.x, y: pointer.y }
: { x: anchorRect.left, y: anchorRect.bottom };
try {
const clicked = await api.contextMenu.show(
[
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
],
position,
);
if (clicked === "copy-mention") {
try {
await writeTextToClipboard(mention);
toastManager.add({ type: "success", title: "Mention copied", description: relativePath });
} catch (error) {
toastManager.add({
type: "error",
title: "Failed to copy mention",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
return;
}
if (clicked === "add-to-chat") {
const inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false;
if (!inserted) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "Open a chat for this project and try again.",
});
}
}
} finally {
context.close();
}
};
const showEntryContextMenuRef = useRef(showEntryContextMenu);
useEffect(() => {
showEntryContextMenuRef.current = showEntryContextMenu;
});

const { model } = useFileTree({
composition: {
contextMenu: {
triggerMode: "right-click",
onOpen: (item, context) => {
void showEntryContextMenuRef.current(item, context);
},
},
},
density: "compact",
fileTreeSearchMode: "hide-non-matches",
flattenEmptyDirectories: true,
Expand Down
29 changes: 29 additions & 0 deletions docs/fork/0001-file-explorer-mention-actions.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
# 0001: File explorer mention actions and zoom-aware context menus

- PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1)
- Status: active

## What you can do now

- Right-click a file or directory in the Files panel and pick **Add to chat**
to drop it into the active composer as a file pill, or **Copy mention** to
put it on the clipboard and paste it wherever it belongs in a prompt.
- Pasting mention text into the composer renders the file pill. This works
for any pasted mention, including ones copied out of an earlier message,
not just ones coming from the file explorer.
- Context menus across the desktop app open at the cursor even when the
window is zoomed. Before this, any zoom level made menus drift away from
the click, worst near the right edge of the window.

## Why

Attaching files to a prompt required typing @-mentions by hand even though
the file explorer already has the file in front of you. Browsing and
prompting should be one flow.

## Upstream considerations

The zoomed context menu drift is a genuine upstream bug and that part is the
strongest candidate to submit once upstream opens contributions. The mention
actions are a feature upstream may or may not want; they depend only on
public composer and tree APIs, so the rebase burden is low.
34 changes: 34 additions & 0 deletions docs/fork/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
# Fork divergence ledger

This directory tracks everything this fork carries that is not in upstream
[pingdotgg/t3code](https://github.com/pingdotgg/t3code). One numbered entry per
divergence, newest last. When upstream ships an equivalent, update the status
here and note what replaced our version instead of deleting the entry, so the
history of what we carried and why stays reconstructable.

## Writing an entry

Entries are product focused. Describe what someone can do now and why we
wanted it, not how it was built. No touched-file lists, component names, or
implementation details; the linked PR already carries all of that, and code
detail in the ledger goes stale the moment the code moves.

Each entry uses these sections:

- **What you can do now**: the user-visible capabilities, as bullets.
- **Why**: the product rationale for carrying the divergence.
- **Upstream considerations**: whether and how we would submit it upstream,
and anything that affects the rebase burden.

## Statuses

- `active`: carried by this fork, not in upstream.
- `submitted`: proposed to upstream, waiting on the outcome.
- `upstreamed`: merged into upstream; our patch was dropped or reconciled.
- `superseded`: upstream shipped a different solution; our patch was dropped.

## Ledger

| # | Divergence | PR | Status |
| ---- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
| 0001 | [File explorer mention actions and zoom-aware context menus](./0001-file-explorer-mention-actions.md) | [#1](https://github.com/TrogonStack/t3code/pull/1) | active |
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
16 changes: 13 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
return normalizedItems;
}

// Renderer positions arrive in CSS pixels; popup() expects window points, so
// page zoom must be factored in or menus drift proportionally to their
// distance from the window origin.
const normalizePosition = (
position: Option.Option<ElectronMenuPosition>,
zoomFactor: number,
): Option.Option<ElectronMenuPosition> =>
Option.filter(
position,
({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
({ x, y }) =>
Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
).pipe(
Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
);

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
Expand DownExpand Up@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {

try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupPosition = normalizePosition(
input.position,
input.window.webContents.getZoomFactor(),
);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ import {
KEY_TAB_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_BACKSPACE_COMMAND,
PASTE_COMMAND,
$getRoot,
HISTORY_MERGE_TAG,
DecoratorNode,
Expand DownExpand Up@@ -55,6 +56,7 @@ import {
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
selectionTouchesMentionBoundary,
splitPromptIntoComposerSegments,
Expand DownExpand Up@@ -1117,6 +1119,80 @@ function ComposerInlineTokenBackspacePlugin() {
return null;
}

function ComposerInlineTokenPastePlugin() {
const [editor] = useLexicalComposerContext();

useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
return false;
}
if (event.clipboardData.files.length > 0) {
return false;
}
const text = event.clipboardData.getData("text/plain");
if (text.length === 0) {
return false;
}
// Token grammar requires trailing whitespace; a virtual newline lets a
// mention at the very end of the pasted text still parse.
const mentions = collectComposerInlineTokens(`${text}\n`).filter(
(token) => token.type === "mention" && token.end <= text.length,
);
if (mentions.length === 0) {
return false;
}
event.preventDefault();
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const nodes: LexicalNode[] = [];
const appendText = (value: string) => {
const lines = value.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
if (line.length > 0) {
nodes.push($createTextNode(line));
}
if (index < lines.length - 1) {
nodes.push($createLineBreakNode());
}
}
};
let cursor = 0;
for (const mention of mentions) {
if (mention.start < cursor) {
continue;
}
if (mention.start > cursor) {
appendText(text.slice(cursor, mention.start));
}
nodes.push($createComposerMentionNode(mention.value));
cursor = mention.end;
}
if (cursor < text.length) {
appendText(text.slice(cursor));
} else {
// Keep the serialized prompt valid: mention tokens need trailing
// whitespace, so a paste ending in a mention gets the same
// trailing space the autocomplete inserts.
nodes.push($createTextNode(" "));
}
selection.insertNodes(nodes);
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor]);

return null;
}

function ComposerSurroundSelectionPlugin(props: {
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
Expand DownExpand Up@@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
import type {
ContextMenuItem as TreeContextMenuItem,
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";

import { toastManager } from "~/components/ui/toast";
import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";

import { useProjectEntriesQuery } from "./projectFilesQueryState";
Expand DownExpand Up@@ -39,6 +48,7 @@ export default function FileBrowserPanel({
onOpenFile,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
const entries = entriesQuery.data?.entries ?? [];
const entryKinds = useMemo(
Expand All@@ -49,7 +59,84 @@ export default function FileBrowserPanel({
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const previousTreePathsRef = useRef<readonly string[]>([]);

// The tree renders rows in shadow DOM and its anchor rect is unreliable, so
// capture the right-click position ourselves; contextmenu is a composed
// event, so a capture-phase listener sees it with viewport coordinates.
const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
useEffect(() => {
const capturePointer = (event: MouseEvent) => {
contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
};
document.addEventListener("contextmenu", capturePointer, true);
return () => document.removeEventListener("contextmenu", capturePointer, true);
}, []);

const showEntryContextMenu = async (
item: TreeContextMenuItem,
context: TreeContextMenuOpenContext,
) => {
const api = readLocalApi();
if (!api) {
context.close();
return;
}
const relativePath = item.path.replace(/\/$/, "");
const mention = serializeComposerFileLink(relativePath);
const pointer = contextMenuPointerRef.current;
const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
const anchorRect = context.anchorElement.getBoundingClientRect();
const position = pointerIsFresh
? { x: pointer.x, y: pointer.y }
: { x: anchorRect.left, y: anchorRect.bottom };
try {
const clicked = await api.contextMenu.show(
[
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
],
position,
);
if (clicked === "copy-mention") {
try {
await writeTextToClipboard(mention);
toastManager.add({ type: "success", title: "Mention copied", description: relativePath });
} catch (error) {
toastManager.add({
type: "error",
title: "Failed to copy mention",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
return;
}
if (clicked === "add-to-chat") {
const inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false;
if (!inserted) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "Open a chat for this project and try again.",
});
}
}
} finally {
context.close();
}
};
const showEntryContextMenuRef = useRef(showEntryContextMenu);
useEffect(() => {
showEntryContextMenuRef.current = showEntryContextMenu;
});

const { model } = useFileTree({
composition: {
contextMenu: {
triggerMode: "right-click",
onOpen: (item, context) => {
void showEntryContextMenuRef.current(item, context);
},
},
},
density: "compact",
fileTreeSearchMode: "hide-non-matches",
flattenEmptyDirectories: true,
Expand Down
29 changes: 29 additions & 0 deletions docs/fork/0001-file-explorer-mention-actions.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
# 0001: File explorer mention actions and zoom-aware context menus

- PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1)
- Status: active

## What you can do now

- Right-click a file or directory in the Files panel and pick **Add to chat**
to drop it into the active composer as a file pill, or **Copy mention** to
put it on the clipboard and paste it wherever it belongs in a prompt.
- Pasting mention text into the composer renders the file pill. This works
for any pasted mention, including ones copied out of an earlier message,
not just ones coming from the file explorer.
- Context menus across the desktop app open at the cursor even when the
window is zoomed. Before this, any zoom level made menus drift away from
the click, worst near the right edge of the window.

## Why

Attaching files to a prompt required typing @-mentions by hand even though
the file explorer already has the file in front of you. Browsing and
prompting should be one flow.

## Upstream considerations

The zoomed context menu drift is a genuine upstream bug and that part is the
strongest candidate to submit once upstream opens contributions. The mention
actions are a feature upstream may or may not want; they depend only on
public composer and tree APIs, so the rebase burden is low.
34 changes: 34 additions & 0 deletions docs/fork/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
# Fork divergence ledger

This directory tracks everything this fork carries that is not in upstream
[pingdotgg/t3code](https://github.com/pingdotgg/t3code). One numbered entry per
divergence, newest last. When upstream ships an equivalent, update the status
here and note what replaced our version instead of deleting the entry, so the
history of what we carried and why stays reconstructable.

## Writing an entry

Entries are product focused. Describe what someone can do now and why we
wanted it, not how it was built. No touched-file lists, component names, or
implementation details; the linked PR already carries all of that, and code
detail in the ledger goes stale the moment the code moves.

Each entry uses these sections:

- **What you can do now**: the user-visible capabilities, as bullets.
- **Why**: the product rationale for carrying the divergence.
- **Upstream considerations**: whether and how we would submit it upstream,
and anything that affects the rebase burden.

## Statuses

- `active`: carried by this fork, not in upstream.
- `submitted`: proposed to upstream, waiting on the outcome.
- `upstreamed`: merged into upstream; our patch was dropped or reconciled.
- `superseded`: upstream shipped a different solution; our patch was dropped.

## Ledger

| # | Divergence | PR | Status |
| ---- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
| 0001 | [File explorer mention actions and zoom-aware context menus](./0001-file-explorer-mention-actions.md) | [#1](https://github.com/TrogonStack/t3code/pull/1) | active |
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
16 changes: 13 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
return normalizedItems;
}

// Renderer positions arrive in CSS pixels; popup() expects window points, so
// page zoom must be factored in or menus drift proportionally to their
// distance from the window origin.
const normalizePosition = (
position: Option.Option<ElectronMenuPosition>,
zoomFactor: number,
): Option.Option<ElectronMenuPosition> =>
Option.filter(
position,
({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
({ x, y }) =>
Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
).pipe(
Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
);

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
Expand DownExpand Up@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {

try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupPosition = normalizePosition(
input.position,
input.window.webContents.getZoomFactor(),
);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ import {
KEY_TAB_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_BACKSPACE_COMMAND,
PASTE_COMMAND,
$getRoot,
HISTORY_MERGE_TAG,
DecoratorNode,
Expand DownExpand Up@@ -55,6 +56,7 @@ import {
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
selectionTouchesMentionBoundary,
splitPromptIntoComposerSegments,
Expand DownExpand Up@@ -1117,6 +1119,80 @@ function ComposerInlineTokenBackspacePlugin() {
return null;
}

function ComposerInlineTokenPastePlugin() {
const [editor] = useLexicalComposerContext();

useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
return false;
}
if (event.clipboardData.files.length > 0) {
return false;
}
const text = event.clipboardData.getData("text/plain");
if (text.length === 0) {
return false;
}
// Token grammar requires trailing whitespace; a virtual newline lets a
// mention at the very end of the pasted text still parse.
const mentions = collectComposerInlineTokens(`${text}\n`).filter(
(token) => token.type === "mention" && token.end <= text.length,
);
if (mentions.length === 0) {
return false;
}
event.preventDefault();
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const nodes: LexicalNode[] = [];
const appendText = (value: string) => {
const lines = value.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
if (line.length > 0) {
nodes.push($createTextNode(line));
}
if (index < lines.length - 1) {
nodes.push($createLineBreakNode());
}
}
};
let cursor = 0;
for (const mention of mentions) {
if (mention.start < cursor) {
continue;
}
if (mention.start > cursor) {
appendText(text.slice(cursor, mention.start));
}
nodes.push($createComposerMentionNode(mention.value));
cursor = mention.end;
}
if (cursor < text.length) {
appendText(text.slice(cursor));
} else {
// Keep the serialized prompt valid: mention tokens need trailing
// whitespace, so a paste ending in a mention gets the same
// trailing space the autocomplete inserts.
nodes.push($createTextNode(" "));
}
selection.insertNodes(nodes);
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor]);

return null;
}

function ComposerSurroundSelectionPlugin(props: {
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
Expand DownExpand Up@@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
import type {
ContextMenuItem as TreeContextMenuItem,
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";

import { toastManager } from "~/components/ui/toast";
import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";

import { useProjectEntriesQuery } from "./projectFilesQueryState";
Expand DownExpand Up@@ -39,6 +48,7 @@ export default function FileBrowserPanel({
onOpenFile,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
const entries = entriesQuery.data?.entries ?? [];
const entryKinds = useMemo(
Expand All@@ -49,7 +59,84 @@ export default function FileBrowserPanel({
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const previousTreePathsRef = useRef<readonly string[]>([]);

// The tree renders rows in shadow DOM and its anchor rect is unreliable, so
// capture the right-click position ourselves; contextmenu is a composed
// event, so a capture-phase listener sees it with viewport coordinates.
const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
useEffect(() => {
const capturePointer = (event: MouseEvent) => {
contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
};
document.addEventListener("contextmenu", capturePointer, true);
return () => document.removeEventListener("contextmenu", capturePointer, true);
}, []);

const showEntryContextMenu = async (
item: TreeContextMenuItem,
context: TreeContextMenuOpenContext,
) => {
const api = readLocalApi();
if (!api) {
context.close();
return;
}
const relativePath = item.path.replace(/\/$/, "");
const mention = serializeComposerFileLink(relativePath);
const pointer = contextMenuPointerRef.current;
const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
const anchorRect = context.anchorElement.getBoundingClientRect();
const position = pointerIsFresh
? { x: pointer.x, y: pointer.y }
: { x: anchorRect.left, y: anchorRect.bottom };
try {
const clicked = await api.contextMenu.show(
[
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
],
position,
);
if (clicked === "copy-mention") {
try {
await writeTextToClipboard(mention);
toastManager.add({ type: "success", title: "Mention copied", description: relativePath });
} catch (error) {
toastManager.add({
type: "error",
title: "Failed to copy mention",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
return;
}
if (clicked === "add-to-chat") {
const inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false;
if (!inserted) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "Open a chat for this project and try again.",
});
}
}
} finally {
context.close();
}
};
const showEntryContextMenuRef = useRef(showEntryContextMenu);
useEffect(() => {
showEntryContextMenuRef.current = showEntryContextMenu;
});

const { model } = useFileTree({
composition: {
contextMenu: {
triggerMode: "right-click",
onOpen: (item, context) => {
void showEntryContextMenuRef.current(item, context);
},
},
},
density: "compact",
fileTreeSearchMode: "hide-non-matches",
flattenEmptyDirectories: true,
Expand Down
29 changes: 29 additions & 0 deletions docs/fork/0001-file-explorer-mention-actions.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
# 0001: File explorer mention actions and zoom-aware context menus

- PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1)
- Status: active

## What you can do now

- Right-click a file or directory in the Files panel and pick **Add to chat**
to drop it into the active composer as a file pill, or **Copy mention** to
put it on the clipboard and paste it wherever it belongs in a prompt.
- Pasting mention text into the composer renders the file pill. This works
for any pasted mention, including ones copied out of an earlier message,
not just ones coming from the file explorer.
- Context menus across the desktop app open at the cursor even when the
window is zoomed. Before this, any zoom level made menus drift away from
the click, worst near the right edge of the window.

## Why

Attaching files to a prompt required typing @-mentions by hand even though
the file explorer already has the file in front of you. Browsing and
prompting should be one flow.

## Upstream considerations

The zoomed context menu drift is a genuine upstream bug and that part is the
strongest candidate to submit once upstream opens contributions. The mention
actions are a feature upstream may or may not want; they depend only on
public composer and tree APIs, so the rebase burden is low.
34 changes: 34 additions & 0 deletions docs/fork/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
# Fork divergence ledger

This directory tracks everything this fork carries that is not in upstream
[pingdotgg/t3code](https://github.com/pingdotgg/t3code). One numbered entry per
divergence, newest last. When upstream ships an equivalent, update the status
here and note what replaced our version instead of deleting the entry, so the
history of what we carried and why stays reconstructable.

## Writing an entry

Entries are product focused. Describe what someone can do now and why we
wanted it, not how it was built. No touched-file lists, component names, or
implementation details; the linked PR already carries all of that, and code
detail in the ledger goes stale the moment the code moves.

Each entry uses these sections:

- **What you can do now**: the user-visible capabilities, as bullets.
- **Why**: the product rationale for carrying the divergence.
- **Upstream considerations**: whether and how we would submit it upstream,
and anything that affects the rebase burden.

## Statuses

- `active`: carried by this fork, not in upstream.
- `submitted`: proposed to upstream, waiting on the outcome.
- `upstreamed`: merged into upstream; our patch was dropped or reconciled.
- `superseded`: upstream shipped a different solution; our patch was dropped.

## Ledger

| # | Divergence | PR | Status |
| ---- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
| 0001 | [File explorer mention actions and zoom-aware context menus](./0001-file-explorer-mention-actions.md) | [#1](https://github.com/TrogonStack/t3code/pull/1) | active |
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
16 changes: 13 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
return normalizedItems;
}

// Renderer positions arrive in CSS pixels; popup() expects window points, so
// page zoom must be factored in or menus drift proportionally to their
// distance from the window origin.
const normalizePosition = (
position: Option.Option<ElectronMenuPosition>,
zoomFactor: number,
): Option.Option<ElectronMenuPosition> =>
Option.filter(
position,
({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
({ x, y }) =>
Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
).pipe(
Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
);

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
Expand DownExpand Up@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {

try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupPosition = normalizePosition(
input.position,
input.window.webContents.getZoomFactor(),
);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ import {
KEY_TAB_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_BACKSPACE_COMMAND,
PASTE_COMMAND,
$getRoot,
HISTORY_MERGE_TAG,
DecoratorNode,
Expand DownExpand Up@@ -55,6 +56,7 @@ import {
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
selectionTouchesMentionBoundary,
splitPromptIntoComposerSegments,
Expand DownExpand Up@@ -1117,6 +1119,80 @@ function ComposerInlineTokenBackspacePlugin() {
return null;
}

function ComposerInlineTokenPastePlugin() {
const [editor] = useLexicalComposerContext();

useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
return false;
}
if (event.clipboardData.files.length > 0) {
return false;
}
const text = event.clipboardData.getData("text/plain");
if (text.length === 0) {
return false;
}
// Token grammar requires trailing whitespace; a virtual newline lets a
// mention at the very end of the pasted text still parse.
const mentions = collectComposerInlineTokens(`${text}\n`).filter(
(token) => token.type === "mention" && token.end <= text.length,
);
if (mentions.length === 0) {
return false;
}
event.preventDefault();
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const nodes: LexicalNode[] = [];
const appendText = (value: string) => {
const lines = value.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
if (line.length > 0) {
nodes.push($createTextNode(line));
}
if (index < lines.length - 1) {
nodes.push($createLineBreakNode());
}
}
};
let cursor = 0;
for (const mention of mentions) {
if (mention.start < cursor) {
continue;
}
if (mention.start > cursor) {
appendText(text.slice(cursor, mention.start));
}
nodes.push($createComposerMentionNode(mention.value));
cursor = mention.end;
}
if (cursor < text.length) {
appendText(text.slice(cursor));
} else {
// Keep the serialized prompt valid: mention tokens need trailing
// whitespace, so a paste ending in a mention gets the same
// trailing space the autocomplete inserts.
nodes.push($createTextNode(" "));
}
selection.insertNodes(nodes);
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor]);

return null;
}

function ComposerSurroundSelectionPlugin(props: {
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
Expand DownExpand Up@@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
import type {
ContextMenuItem as TreeContextMenuItem,
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";

import { toastManager } from "~/components/ui/toast";
import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";

import { useProjectEntriesQuery } from "./projectFilesQueryState";
Expand DownExpand Up@@ -39,6 +48,7 @@ export default function FileBrowserPanel({
onOpenFile,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
const entries = entriesQuery.data?.entries ?? [];
const entryKinds = useMemo(
Expand All@@ -49,7 +59,84 @@ export default function FileBrowserPanel({
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const previousTreePathsRef = useRef<readonly string[]>([]);

// The tree renders rows in shadow DOM and its anchor rect is unreliable, so
// capture the right-click position ourselves; contextmenu is a composed
// event, so a capture-phase listener sees it with viewport coordinates.
const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
useEffect(() => {
const capturePointer = (event: MouseEvent) => {
contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
};
document.addEventListener("contextmenu", capturePointer, true);
return () => document.removeEventListener("contextmenu", capturePointer, true);
}, []);

const showEntryContextMenu = async (
item: TreeContextMenuItem,
context: TreeContextMenuOpenContext,
) => {
const api = readLocalApi();
if (!api) {
context.close();
return;
}
const relativePath = item.path.replace(/\/$/, "");
const mention = serializeComposerFileLink(relativePath);
const pointer = contextMenuPointerRef.current;
const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
const anchorRect = context.anchorElement.getBoundingClientRect();
const position = pointerIsFresh
? { x: pointer.x, y: pointer.y }
: { x: anchorRect.left, y: anchorRect.bottom };
try {
const clicked = await api.contextMenu.show(
[
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
],
position,
);
if (clicked === "copy-mention") {
try {
await writeTextToClipboard(mention);
toastManager.add({ type: "success", title: "Mention copied", description: relativePath });
} catch (error) {
toastManager.add({
type: "error",
title: "Failed to copy mention",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
return;
}
if (clicked === "add-to-chat") {
const inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false;
if (!inserted) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "Open a chat for this project and try again.",
});
}
}
} finally {
context.close();
}
};
const showEntryContextMenuRef = useRef(showEntryContextMenu);
useEffect(() => {
showEntryContextMenuRef.current = showEntryContextMenu;
});

const { model } = useFileTree({
composition: {
contextMenu: {
triggerMode: "right-click",
onOpen: (item, context) => {
void showEntryContextMenuRef.current(item, context);
},
},
},
density: "compact",
fileTreeSearchMode: "hide-non-matches",
flattenEmptyDirectories: true,
Expand Down
29 changes: 29 additions & 0 deletions docs/fork/0001-file-explorer-mention-actions.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
# 0001: File explorer mention actions and zoom-aware context menus

- PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1)
- Status: active

## What you can do now

- Right-click a file or directory in the Files panel and pick **Add to chat**
to drop it into the active composer as a file pill, or **Copy mention** to
put it on the clipboard and paste it wherever it belongs in a prompt.
- Pasting mention text into the composer renders the file pill. This works
for any pasted mention, including ones copied out of an earlier message,
not just ones coming from the file explorer.
- Context menus across the desktop app open at the cursor even when the
window is zoomed. Before this, any zoom level made menus drift away from
the click, worst near the right edge of the window.

## Why

Attaching files to a prompt required typing @-mentions by hand even though
the file explorer already has the file in front of you. Browsing and
prompting should be one flow.

## Upstream considerations

The zoomed context menu drift is a genuine upstream bug and that part is the
strongest candidate to submit once upstream opens contributions. The mention
actions are a feature upstream may or may not want; they depend only on
public composer and tree APIs, so the rebase burden is low.
34 changes: 34 additions & 0 deletions docs/fork/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
# Fork divergence ledger

This directory tracks everything this fork carries that is not in upstream
[pingdotgg/t3code](https://github.com/pingdotgg/t3code). One numbered entry per
divergence, newest last. When upstream ships an equivalent, update the status
here and note what replaced our version instead of deleting the entry, so the
history of what we carried and why stays reconstructable.

## Writing an entry

Entries are product focused. Describe what someone can do now and why we
wanted it, not how it was built. No touched-file lists, component names, or
implementation details; the linked PR already carries all of that, and code
detail in the ledger goes stale the moment the code moves.

Each entry uses these sections:

- **What you can do now**: the user-visible capabilities, as bullets.
- **Why**: the product rationale for carrying the divergence.
- **Upstream considerations**: whether and how we would submit it upstream,
and anything that affects the rebase burden.

## Statuses

- `active`: carried by this fork, not in upstream.
- `submitted`: proposed to upstream, waiting on the outcome.
- `upstreamed`: merged into upstream; our patch was dropped or reconciled.
- `superseded`: upstream shipped a different solution; our patch was dropped.

## Ledger

| # | Divergence | PR | Status |
| ---- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
| 0001 | [File explorer mention actions and zoom-aware context menus](./0001-file-explorer-mention-actions.md) | [#1](https://github.com/TrogonStack/t3code/pull/1) | active |
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
16 changes: 13 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
return normalizedItems;
}

// Renderer positions arrive in CSS pixels; popup() expects window points, so
// page zoom must be factored in or menus drift proportionally to their
// distance from the window origin.
const normalizePosition = (
position: Option.Option<ElectronMenuPosition>,
zoomFactor: number,
): Option.Option<ElectronMenuPosition> =>
Option.filter(
position,
({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
({ x, y }) =>
Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
).pipe(
Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
);

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
Expand DownExpand Up@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {

try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupPosition = normalizePosition(
input.position,
input.window.webContents.getZoomFactor(),
);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ import {
KEY_TAB_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_BACKSPACE_COMMAND,
PASTE_COMMAND,
$getRoot,
HISTORY_MERGE_TAG,
DecoratorNode,
Expand DownExpand Up@@ -55,6 +56,7 @@ import {
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
selectionTouchesMentionBoundary,
splitPromptIntoComposerSegments,
Expand DownExpand Up@@ -1117,6 +1119,80 @@ function ComposerInlineTokenBackspacePlugin() {
return null;
}

function ComposerInlineTokenPastePlugin() {
const [editor] = useLexicalComposerContext();

useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
return false;
}
if (event.clipboardData.files.length > 0) {
return false;
}
const text = event.clipboardData.getData("text/plain");
if (text.length === 0) {
return false;
}
// Token grammar requires trailing whitespace; a virtual newline lets a
// mention at the very end of the pasted text still parse.
const mentions = collectComposerInlineTokens(`${text}\n`).filter(
(token) => token.type === "mention" && token.end <= text.length,
);
if (mentions.length === 0) {
return false;
}
event.preventDefault();
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const nodes: LexicalNode[] = [];
const appendText = (value: string) => {
const lines = value.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
if (line.length > 0) {
nodes.push($createTextNode(line));
}
if (index < lines.length - 1) {
nodes.push($createLineBreakNode());
}
}
};
let cursor = 0;
for (const mention of mentions) {
if (mention.start < cursor) {
continue;
}
if (mention.start > cursor) {
appendText(text.slice(cursor, mention.start));
}
nodes.push($createComposerMentionNode(mention.value));
cursor = mention.end;
}
if (cursor < text.length) {
appendText(text.slice(cursor));
} else {
// Keep the serialized prompt valid: mention tokens need trailing
// whitespace, so a paste ending in a mention gets the same
// trailing space the autocomplete inserts.
nodes.push($createTextNode(" "));
}
selection.insertNodes(nodes);
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor]);

return null;
}

function ComposerSurroundSelectionPlugin(props: {
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
Expand DownExpand Up@@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
import type {
ContextMenuItem as TreeContextMenuItem,
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";

import { toastManager } from "~/components/ui/toast";
import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";

import { useProjectEntriesQuery } from "./projectFilesQueryState";
Expand DownExpand Up@@ -39,6 +48,7 @@ export default function FileBrowserPanel({
onOpenFile,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
const entries = entriesQuery.data?.entries ?? [];
const entryKinds = useMemo(
Expand All@@ -49,7 +59,84 @@ export default function FileBrowserPanel({
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const previousTreePathsRef = useRef<readonly string[]>([]);

// The tree renders rows in shadow DOM and its anchor rect is unreliable, so
// capture the right-click position ourselves; contextmenu is a composed
// event, so a capture-phase listener sees it with viewport coordinates.
const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
useEffect(() => {
const capturePointer = (event: MouseEvent) => {
contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
};
document.addEventListener("contextmenu", capturePointer, true);
return () => document.removeEventListener("contextmenu", capturePointer, true);
}, []);

const showEntryContextMenu = async (
item: TreeContextMenuItem,
context: TreeContextMenuOpenContext,
) => {
const api = readLocalApi();
if (!api) {
context.close();
return;
}
const relativePath = item.path.replace(/\/$/, "");
const mention = serializeComposerFileLink(relativePath);
const pointer = contextMenuPointerRef.current;
const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
const anchorRect = context.anchorElement.getBoundingClientRect();
const position = pointerIsFresh
? { x: pointer.x, y: pointer.y }
: { x: anchorRect.left, y: anchorRect.bottom };
try {
const clicked = await api.contextMenu.show(
[
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
],
position,
);
if (clicked === "copy-mention") {
try {
await writeTextToClipboard(mention);
toastManager.add({ type: "success", title: "Mention copied", description: relativePath });
} catch (error) {
toastManager.add({
type: "error",
title: "Failed to copy mention",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
return;
}
if (clicked === "add-to-chat") {
const inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false;
if (!inserted) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "Open a chat for this project and try again.",
});
}
}
} finally {
context.close();
}
};
const showEntryContextMenuRef = useRef(showEntryContextMenu);
useEffect(() => {
showEntryContextMenuRef.current = showEntryContextMenu;
});

const { model } = useFileTree({
composition: {
contextMenu: {
triggerMode: "right-click",
onOpen: (item, context) => {
void showEntryContextMenuRef.current(item, context);
},
},
},
density: "compact",
fileTreeSearchMode: "hide-non-matches",
flattenEmptyDirectories: true,
Expand Down
29 changes: 29 additions & 0 deletions docs/fork/0001-file-explorer-mention-actions.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
# 0001: File explorer mention actions and zoom-aware context menus

- PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1)
- Status: active

## What you can do now

- Right-click a file or directory in the Files panel and pick **Add to chat**
to drop it into the active composer as a file pill, or **Copy mention** to
put it on the clipboard and paste it wherever it belongs in a prompt.
- Pasting mention text into the composer renders the file pill. This works
for any pasted mention, including ones copied out of an earlier message,
not just ones coming from the file explorer.
- Context menus across the desktop app open at the cursor even when the
window is zoomed. Before this, any zoom level made menus drift away from
the click, worst near the right edge of the window.

## Why

Attaching files to a prompt required typing @-mentions by hand even though
the file explorer already has the file in front of you. Browsing and
prompting should be one flow.

## Upstream considerations

The zoomed context menu drift is a genuine upstream bug and that part is the
strongest candidate to submit once upstream opens contributions. The mention
actions are a feature upstream may or may not want; they depend only on
public composer and tree APIs, so the rebase burden is low.
34 changes: 34 additions & 0 deletions docs/fork/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
# Fork divergence ledger

This directory tracks everything this fork carries that is not in upstream
[pingdotgg/t3code](https://github.com/pingdotgg/t3code). One numbered entry per
divergence, newest last. When upstream ships an equivalent, update the status
here and note what replaced our version instead of deleting the entry, so the
history of what we carried and why stays reconstructable.

## Writing an entry

Entries are product focused. Describe what someone can do now and why we
wanted it, not how it was built. No touched-file lists, component names, or
implementation details; the linked PR already carries all of that, and code
detail in the ledger goes stale the moment the code moves.

Each entry uses these sections:

- **What you can do now**: the user-visible capabilities, as bullets.
- **Why**: the product rationale for carrying the divergence.
- **Upstream considerations**: whether and how we would submit it upstream,
and anything that affects the rebase burden.

## Statuses

- `active`: carried by this fork, not in upstream.
- `submitted`: proposed to upstream, waiting on the outcome.
- `upstreamed`: merged into upstream; our patch was dropped or reconciled.
- `superseded`: upstream shipped a different solution; our patch was dropped.

## Ledger

| # | Divergence | PR | Status |
| ---- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
| 0001 | [File explorer mention actions and zoom-aware context menus](./0001-file-explorer-mention-actions.md) | [#1](https://github.com/TrogonStack/t3code/pull/1) | active |