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
15 changes: 11 additions & 4 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ import { MarkdownExternalLinkContent } from "./chat/MarkdownExternalLink";
import { MarkdownFileLink } from "./chat/MarkdownFileLink";
import { canUseMarkdownFileShellActions } from "./chat/MarkdownFileLink.logic";
import { handleMarkdownFragmentClick, rehypeHeadingIds } from "./chat/markdownFragments";
import { rehypeTextDirection } from "./chat/markdownTextDirection";
import { hastHasText, hastPlainTextDeep, plainHastText } from "./chat/markdownHast.logic";
import {
extractCodeBlock,
Expand Down Expand Up @@ -384,11 +385,13 @@ function normalizeMarkdownLinkHrefKey(href: string): string {
const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [
...CHAT_MARKDOWN_REHYPE_PLUGINS,
rehypeHeadingIds,
rehypeTextDirection,
] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;

const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable<
ReactMarkdownOptions["rehypePlugins"]
>;
const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [
rehypeHeadingIds,
rehypeTextDirection,
] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;

const NO_MARKDOWN_EDITORS: ReadonlyArray<EditorId> = [];

Expand Down Expand Up @@ -975,7 +978,11 @@ const CHAT_MARKDOWN_COMPONENTS = {
return <blockquote {...props}>{children}</blockquote>;
}
return (
<div role="note" className={cn("my-1 border-l-2 pl-3", alert.borderClassName)}>
<div
role="note"
dir={props.dir}
className={cn("my-1 border-s-2 ps-3", alert.borderClassName)}
>
<p
data-thread-find-ignore="true"
className={cn("flex items-center gap-1.5 font-medium", alert.titleClassName)}
Expand Down
17 changes: 13 additions & 4 deletions apps/web/src/components/CommandPaletteResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react";
import { ChevronRightIcon } from "lucide-react";
import { type ReactNode, type RefObject, useState } from "react";
import { cn } from "~/lib/utils";
import { textDirection } from "~/textDirection";
import { shortcutLabelForCommand } from "../keybindings";
import {
type CommandPaletteActionItem,
Expand Down Expand Up @@ -154,6 +155,14 @@ export function CommandPaletteTitleIcon(props: { children: ReactNode }) {
return <span className="flex h-5 shrink-0 items-center self-start pt-0.5">{props.children}</span>;
}

function CommandPaletteTitle({ title }: { title: ReactNode }) {
return (
<span className="truncate" dir={typeof title === "string" ? textDirection(title) : undefined}>
{title}
</span>
);
}

function DisabledCommandPaletteResultRow(props: {
item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
}) {
Expand All @@ -164,7 +173,7 @@ function DisabledCommandPaletteResultRow(props: {
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
{props.item.titleLeadingContent}
<span className="truncate">{props.item.title}</span>
<CommandPaletteTitle title={props.item.title} />
</span>
{props.item.threadContentMatch ? (
<ThreadSearchMatchExcerpt match={props.item.threadContentMatch} />
Expand All @@ -178,7 +187,7 @@ function DisabledCommandPaletteResultRow(props: {
) : (
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
{props.item.titleLeadingContent}
<span className="truncate">{props.item.title}</span>
<CommandPaletteTitle title={props.item.title} />
</span>
)}
{props.item.titleTrailingContent}
Expand Down Expand Up @@ -214,7 +223,7 @@ function CommandPaletteResultRow(props: {
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
{props.item.titleLeadingContent}
<span className="truncate">{props.item.title}</span>
<CommandPaletteTitle title={props.item.title} />
</span>
{props.item.threadContentMatch ? (
<ThreadSearchMatchExcerpt match={props.item.threadContentMatch} />
Expand All @@ -228,7 +237,7 @@ function CommandPaletteResultRow(props: {
) : (
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
{props.item.titleLeadingContent}
<span className="truncate">{props.item.title}</span>
<CommandPaletteTitle title={props.item.title} />
</span>
)}
{props.item.titleTrailingContent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
import type { ServerProviderSkill } from "@t3tools/contracts";
import type { Editor } from "@tiptap/core";
import { createElement, createRef, useEffect, useState } from "react";
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, vi } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";

import { act, render } from "../../test/render";

Expand Down Expand Up @@ -352,3 +353,63 @@ describe("composer context undo lifetime", () => {
},
);
});

describe("an Arabic line", () => {
async function focusEnd(editor: Editor) {
await act(async () => {
editor.commands.focus("end");
});
await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom));
}

it("leaves inline code through its start with ArrowRight, which points back", async () => {
// Positions: اكتب at 1-5, sumBy at 6-11, الان at 12-16.
await using screen = await mountEditor("اكتب `sumBy` الان");
const { editor } = screen;
await focusEnd(editor);
const caret = () => ({
head: editor.state.selection.head,
inCode: (editor.state.storedMarks ?? editor.state.selection.$head.marks()).length > 0,
});
const visited = [caret()];
while (visited.at(-1)!.head > 5) {
const before = visited.at(-1)!;
await userEvent.keyboard("{ArrowRight}");
await vi.waitFor(() => {
const after = caret();
expect(after.head < before.head || after.inCode !== before.inCode).toBe(true);
});
visited.push(caret());
}
expect(visited.some((stop) => stop.inCode)).toBe(true);
});

it("shows the backticks on either side of inline code it is editing", async () => {
await using screen = await mountEditor("اكتب `sumBy` الان");
const { editor } = screen;
await act(async () => {
editor.commands.setTextSelection("اكتب su".length + 1);
});
const code = editor.view.dom.querySelector("code")!;
const word = document.createRange();
word.selectNodeContents([...code.childNodes].find((node) => node instanceof Text)!);
const text = word.getBoundingClientRect();
const [open, close] = [...editor.view.dom.querySelectorAll(".composer-rich-marker")].map(
(marker) => marker.getBoundingClientRect(),
);
expect(open!.right).toBeLessThanOrEqual(text.left + 1);
expect(close!.left).toBeGreaterThanOrEqual(text.right - 1);
});

it("starts the next line on the right", async () => {
await using screen = await mountEditor("اكتب الدالة");
const { editor } = screen;
await focusEnd(editor);
await userEvent.keyboard("{Shift>}{Enter}{/Shift}");
await vi.waitFor(() => expect(editor.state.doc.childCount).toBe(2));
const box = editor.view.dom.getBoundingClientRect();
expect(editor.view.coordsAtPos(editor.state.selection.head).left).toBeGreaterThan(
box.left + box.width / 2,
);
});
});
23 changes: 20 additions & 3 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
pastedPathQueryLength,
} from "~/composer-logic";
import { useStableCallback } from "~/hooks/useStableCallback";
import { caretLineDirection, ComposerTextDirectionExtension } from "~/composer-text-direction";
import {
collectComposerPromptInlineTokens,
selectionTouchesMentionBoundary,
Expand Down Expand Up @@ -264,6 +265,7 @@ function ComposerMentionNodeView({ node }: NodeViewProps) {
const chip = (
<ContextChip
kind="mention"
dir="ltr"
render={<button type="button" />}
onClick={() => actions.openMention(path)}
aria-label={`Preview ${path}`}
Expand Down Expand Up @@ -727,6 +729,7 @@ const ComposerMarkerPlugin = new Plugin({
function decorationsForSelection(state: EditorState): DecorationSet {
const { doc, selection } = state;
const caretSide = caretTakesMarksBefore(state) ? 3 : 0;
const rtl = caretLineDirection(state) === "rtl";
const decorations: Decoration[] = [];
if (!selection.empty) {
doc.nodesBetween(selection.from, selection.to, (node, pos) => {
Expand All @@ -750,8 +753,19 @@ function decorationsForSelection(state: EditorState): DecorationSet {
marker.className = "composer-rich-marker";
marker.textContent = text;
marker.setAttribute("aria-hidden", "true");
// Inline code is isolated left to right. In a right-to-left line its
// opening backtick joins the closing one inside the isolate, or the line
// would put both on the same side of the code.
const codeMarks =
rtl && baseSide === -1 && /^`+$/.test(text)
? doc.resolve(at).nodeAfter?.marks.filter((mark) => mark.type.spec.code)
: undefined;
decorations.push(
Decoration.widget(at, marker, { side, key: `marker-${at}-${side}-${marker.textContent}` }),
Decoration.widget(at, marker, {
side,
key: `marker-${at}-${side}-${marker.textContent}`,
...(codeMarks?.length ? { marks: codeMarks } : {}),
}),
);
}
}
Expand Down Expand Up @@ -852,6 +866,7 @@ function createComposerExtensions(richText: boolean) {
...(richText ? {} : { bold: false, strike: false }),
}),
ComposerUndoGroupingExtension,
ComposerTextDirectionExtension,
ComposerMentionExtension,
ComposerSkillExtension,
ComposerCitationExtension,
Expand Down Expand Up @@ -1146,6 +1161,8 @@ function ComposerPromptEditorTiptapInner(
editorProps: {
attributes: editorAttributes,
handleKeyDown: (view, event) => {
// In a right-to-left line the start is on the right.
const backArrow = caretLineDirection(view.state) === "rtl" ? "ArrowRight" : "ArrowLeft";
const boundary = event.metaKey
? event.key === "ArrowLeft" || event.key === "ArrowRight"
? "lineboundary"
Expand All @@ -1171,7 +1188,7 @@ function ComposerPromptEditorTiptapInner(
event.preventDefault();
event.stopPropagation();
const backward =
event.key === "Home" || event.key === "ArrowLeft" || event.key === "ArrowUp";
event.key === "Home" || event.key === "ArrowUp" || event.key === backArrow;
selection.modify(
event.shiftKey ? "extend" : "move",
backward ? "backward" : "forward",
Expand Down Expand Up @@ -1206,7 +1223,7 @@ function ComposerPromptEditorTiptapInner(
view.state.selection.empty
) {
const { $from } = view.state.selection;
const direction = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1;
const direction = event.key === "ArrowUp" || event.key === backArrow ? -1 : 1;
const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
// At the document's ends, native Up/Down can cross a marker without
// changing the document position. Record the stop so DOM selection
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from "../WorkspaceBreadcrumb";
import { observeResize } from "~/lib/observeResize";
import { cn } from "~/lib/utils";
import { textDirection } from "~/textDirection";
import { useClientSettings } from "../../hooks/useSettings";

interface ChatHeaderProps {
Expand Down Expand Up @@ -342,6 +343,7 @@ export const ChatHeader = memo(function ChatHeader({
{renamingTitle !== null ? (
<input
autoFocus
dir="auto"
aria-label="Thread title"
className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-inset ring-ring/50 focus:ring-ring"
defaultValue={renamingTitle}
Expand Down Expand Up @@ -375,7 +377,7 @@ export const ChatHeader = memo(function ChatHeader({
/>
}
>
<h2 className="min-w-0">
<h2 className="min-w-0" dir={textDirection(activeThreadTitle)}>
<WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText>
</h2>
<ChevronDownIcon
Expand All @@ -389,7 +391,13 @@ export const ChatHeader = memo(function ChatHeader({
) : (
<Tooltip>
<TooltipTrigger
render={<h2 aria-label={activeThreadTitle} className="min-w-0 flex-1" />}
render={
<h2
aria-label={activeThreadTitle}
className="min-w-0 flex-1"
dir={textDirection(activeThreadTitle)}
/>
}
>
<WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText>
</TooltipTrigger>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/MarkdownCodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function MarkdownCodeBlockFrame({
}) {
return (
<Wrapper
dir="ltr"
className="chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-lg border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32"
data-language={language}
data-wrap={wrapped ? "true" : "false"}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/MarkdownFileLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({
hasPrimaryAction ? (
<ContextChip
kind="mention"
dir="ltr"
render={<a href={href} />}
className={MARKDOWN_FILE_LINK_CLASS_NAME}
data-markdown-copy={copyMarkdown}
Expand Down Expand Up @@ -356,6 +357,7 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({
) : (
<ContextChip
kind="mention"
dir="ltr"
render={<button type="button" />}
aria-label={`File options for ${label}`}
aria-haspopup="menu"
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/chat/markdownTextDirection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vite-plus/test";

import { rehypeTextDirection } from "./markdownTextDirection";

const text = (value: string) => ({ type: "text", value });
const element = (tagName: string, children: object[]) => ({
type: "element",
tagName,
properties: {} as Record<string, unknown>,
children,
});

function directed(...blocks: ReturnType<typeof element>[]) {
rehypeTextDirection()({ type: "root", children: blocks });
return blocks;
}

describe("rehypeTextDirection", () => {
it("keeps raw HTML list items apart when counting words", () => {
const [list] = directed(
element("ul", [element("li", [text("مرحبا")]), element("li", [text("hello world")])]),
);
expect(list!.properties.dir).toBe("ltr");
});

it("leaves inline code out of the count and isolates it", () => {
const code = element("code", [text("useEffect useLayoutEffect React")]);
const [paragraph] = directed(element("p", [text("استخدم "), code, text(" هنا")]));
expect(paragraph!.properties.dir).toBe("rtl");
expect(code.properties.dir).toBe("ltr");
});

it("leaves code blocks to their own left-to-right frame", () => {
const code = element("code", [text("// احسب المجموع هنا")]);
const [pre] = directed(element("pre", [code]));
expect(pre!.properties.dir).toBeUndefined();
expect(code.properties.dir).toBeUndefined();
});
});
Loading
Loading