You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/27 04:08:33 UTC
(maka) branch feat/ui-quote-comments updated: refactor(ui): drop the numbered quote markers, keep the highlight
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch feat/ui-quote-comments
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/feat/ui-quote-comments by this push:
new d66da82162 refactor(ui): drop the numbered quote markers, keep the highlight
d66da82162 is described below
commit d66da821620ace5fcede8fb30ea51d2bcab37aca
Author: AstroHan <le...@outlook.com>
AuthorDate: Sun Sep 27 12:08:28 2026 +0800
refactor(ui): drop the numbered quote markers, keep the highlight
Composer tokens carry no numbers, so the 1/2 badges pinned at each
excerpt's end only mapped to token order. The highlight already shows
which excerpts are quoted, and clicking a token opens its note over its
own excerpt, which disambiguates on demand.
Removes the marker state, its position measuring on every scroll, the
body portal and the ordinal CSS. The panel still follows its excerpt: a
scroll listener re-renders only while a note panel is open, where the
old listener re-rendered whenever any staged quote was on screen.
The note panel also stopped covering the text it annotates: it was
anchored 12px below the excerpt's top edge. It now hangs below the
excerpt, or above it when the window has no room below (the common case
for the newest reply, which sits right over the composer). The height
estimate for that fit is corrected from 280px to the panel's real
maximum (about 150px at four input rows).
The stories now assert exactly which excerpts are painted, and that the
panel hangs next to its own excerpt without overlapping it.
Generated-by: Devin
---
docs/astryx-surface-file-inventory.md | 2 +-
packages/ui/src/chat-view.tsx | 150 ++++++++++----------------
packages/ui/src/styles.css | 3 -
packages/ui/stories/quote-comment.stories.tsx | 95 ++++++++--------
4 files changed, 100 insertions(+), 150 deletions(-)
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 7fe004a9e9..b7047ecd9a 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -264,7 +264,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, Selector, SelectorOption | aligned — uses Astryx (Button, Selector, SelectorOption) | aligned |
| `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned |
| `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, HStack) | aligned |
-| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Badge, Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner | aligned — uses Astryx (Badge, Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner) | aligned |
+| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner) | aligned |
| `packages/ui/src/choice-panel.tsx` | shell-chrome-or-panel | Badge, Item | aligned — uses Astryx (Badge, Item) | aligned |
| `packages/ui/src/client-capability-prompt.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned |
| `packages/ui/src/client-plugin-runtime.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx
index e2c745949b..ae875eaf6e 100644
--- a/packages/ui/src/chat-view.tsx
+++ b/packages/ui/src/chat-view.tsx
@@ -24,6 +24,7 @@ import {
useImperativeHandle,
useLayoutEffect,
useMemo,
+ useReducer,
useRef,
useState,
type CSSProperties,
@@ -55,8 +56,7 @@ import type {
QuoteRef,
ShellRunUpdate,
} from '@maka/core/events';
-import { createPortal } from 'react-dom';
-import { Badge, Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner } from '@astryxdesign/core';
+import { Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner } from '@astryxdesign/core';
import { useChatLayoutContext } from '@astryxdesign/core/Chat';
import { useLayer } from '@astryxdesign/core/Layer';
import { finalAssistantReplyText } from './materialize.js';
@@ -99,45 +99,20 @@ import {
*/
const MEASURE_AHEAD_MARGIN = 2000;
-/**
- * Height the annotation panel is laid out for, used only to keep it inside the
- * window when the excerpt it hangs from sits near the bottom edge.
- */
-const QUOTE_ANNOTATION_PANEL_HEIGHT = 280;
+/** The note panel at its tallest (the input grows to four rows); decides
+ * whether it fits below its excerpt. */
+const QUOTE_ANNOTATION_PANEL_HEIGHT = 160;
// Half the panel's fixed 320px width plus an edge margin — the panel is
// center-anchored, so x must stay this far inside the window.
const QUOTE_ANNOTATION_PANEL_HALF = 168;
+/** The note panel hangs centred below its excerpt, or above it when the window
+ * has no room below, so the text it annotates stays readable. */
+const excerptAnchor = (box: DOMRect) => ({
+ x: box.left + box.width / 2,
+ top: box.top,
+ bottom: box.bottom,
+});
-interface QuoteMark {
- index: number;
- x: number;
- y: number;
-}
-
-const sameQuoteMarks = (a: QuoteMark[], b: QuoteMark[]): boolean =>
- a.length === b.length &&
- a.every((mark, i) => mark.index === b[i].index && mark.x === b[i].x && mark.y === b[i].y);
-
-/**
- * Viewport position of each excerpt's last line, or nothing when the excerpt
- * has left the transcript's scrollport — a pin parked there would float over
- * the composer or header it does not belong to.
- */
-const quoteMarksAt = (
- root: HTMLElement | null,
- found: readonly { index: number; range: Range }[],
-): QuoteMark[] => {
- const band = root?.getBoundingClientRect();
- const marks: QuoteMark[] = [];
- for (const { index, range } of found) {
- const rects = range.getClientRects();
- const last = rects[rects.length - 1];
- if (!last) continue;
- if (band && (last.bottom < band.top || last.top > band.bottom)) continue;
- marks.push({ index, x: last.right, y: last.top });
- }
- return marks;
-};
export interface LiveContentActivationSnapshot {
turnId: string;
@@ -386,9 +361,7 @@ export function ChatView(props: {
onQuoteAnnotationSubmit?(index: number, comment: string): void;
/**
* The host's staged quotes. Each one whose excerpt still lives in this
- * transcript keeps a painted highlight and a numbered marker at the
- * excerpt's end, so several annotations stay distinguishable by position;
- * a fresh annotation is numbered with the slot it will take.
+ * transcript stays highlighted there.
*/
pendingQuotes?: readonly QuoteRef[];
} & ChatViewGoalIndicatorProps) {
@@ -666,7 +639,7 @@ export function ChatView(props: {
kind: 'annotate' | 'edit';
text: string;
turnId: string;
- anchor: { x: number; y: number };
+ anchor: { x: number; top: number; bottom: number };
} | null>(null);
const annotationIndex = !quoteAnnotation
? -1
@@ -684,8 +657,7 @@ export function ChatView(props: {
const index = quoteAnnotation ? orderedTurnIds.indexOf(quoteAnnotation.turnId) : -1;
if (index !== -1) keepMountedIndexes.add(index);
}
- const [quoteMarks, setQuoteMarks] = useState<QuoteMark[]>([]);
- const quoteMarkRangesRef = useRef<{ index: number; range: Range }[]>([]);
+ const quoteRangesRef = useRef<{ index: number; range: Range }[]>([]);
const barVisible = selectionQuote !== null && quoteAnnotation === null;
const panelVisible = quoteAnnotation !== null;
// The bar and the panel are separate layers on purpose: useLayer maps
@@ -744,7 +716,7 @@ export function ChatView(props: {
openQuoteAnnotation(index) {
const root = scrollRef.current;
const quote = props.pendingQuotes?.[index];
- const range = quoteMarkRangesRef.current.find((mark) => mark.index === index)?.range;
+ const range = quoteRangesRef.current.find((found) => found.index === index)?.range;
// Taking over an in-flight annotation would discard the note already
// typed into it, so the token falls back to its own popover instead.
if (!root || !quote?.sourceTurnId || !range || quoteAnnotation !== null) return false;
@@ -762,20 +734,18 @@ export function ChatView(props: {
kind: 'edit',
text: quote.text,
turnId: quote.sourceTurnId,
- anchor: { x: box.left + box.width / 2, y: box.top },
+ anchor: excerptAnchor(box),
});
return true;
},
}));
// Every excerpt carrying a quote — each staged one and the excerpt a fresh
- // annotation is written on — keeps a painted highlight plus a numbered pin
- // at its end. The mark is painted rather than selected because the note
- // input owns the DOM selection while a note is written. Ranges are
- // re-found after every commit because the virtualizer remounts turns
- // underneath us, and positions re-measure on capture-phase scroll because
- // a scroll that swaps nothing produces no commit.
- const measureQuoteMarks = (): { index: number; range: Range }[] => {
+ // note is written on — stays painted. It is painted rather than selected
+ // because the note input owns the DOM selection while a note is written.
+ // Ranges are re-found after every commit because the virtualizer remounts
+ // turns underneath us.
+ const measureQuoteRanges = (): { index: number; range: Range }[] => {
const found: { index: number; range: Range }[] = [];
const root = scrollRef.current;
const collect = (index: number, turnId: string | undefined, text: string) => {
@@ -786,7 +756,7 @@ export function ChatView(props: {
};
props.pendingQuotes?.forEach((quote, index) => {
// Cross-session snapshots stage in the references row, not on this
- // transcript — a colliding turn id must not pin them here.
+ // transcript — a colliding turn id must not paint them here.
if (!quote.sourceSessionId) collect(index, quote.sourceTurnId, quote.text);
});
if (quoteAnnotation?.kind === 'annotate') {
@@ -796,16 +766,14 @@ export function ChatView(props: {
};
useLayoutEffect(() => {
- const found = measureQuoteMarks();
- quoteMarkRangesRef.current = found;
+ const found = measureQuoteRanges();
+ quoteRangesRef.current = found;
const highlights = typeof CSS !== 'undefined' ? CSS.highlights : undefined;
let highlight: Highlight | null = null;
if (found.length > 0 && typeof Highlight !== 'undefined' && highlights) {
highlight = new Highlight(...found.map(({ range }) => range));
highlights.set('maka-quote-mark', highlight);
}
- const marks = quoteMarksAt(scrollRef.current, found);
- setQuoteMarks((current) => (sameQuoteMarks(current, marks) ? current : marks));
// The registry is document-global: only remove an entry this ChatView
// still owns, never one a co-mounted transcript painted over it.
return () => {
@@ -815,18 +783,19 @@ export function ChatView(props: {
};
});
+ // A scroll moves the excerpt without a commit; while the panel hangs from
+ // it, re-render so the panel follows.
+ const [, followScroll] = useReducer((tick: number) => tick + 1, 0);
useEffect(() => {
- const onScroll = () => {
- const marks = quoteMarksAt(scrollRef.current, quoteMarkRangesRef.current);
- setQuoteMarks((current) => (sameQuoteMarks(current, marks) ? current : marks));
- };
- document.addEventListener('scroll', onScroll, { capture: true, passive: true });
- window.addEventListener('resize', onScroll);
+ if (!panelVisible) return;
+ const follow = () => followScroll();
+ document.addEventListener('scroll', follow, { capture: true, passive: true });
+ window.addEventListener('resize', follow);
return () => {
- document.removeEventListener('scroll', onScroll, { capture: true });
- window.removeEventListener('resize', onScroll);
+ document.removeEventListener('scroll', follow, { capture: true });
+ window.removeEventListener('resize', follow);
};
- }, [scrollRef]);
+ }, [panelVisible]);
// Removing the edited quote's token, or sending, leaves nothing to save to.
useLayoutEffect(() => {
@@ -835,12 +804,13 @@ export function ChatView(props: {
// The panel follows the excerpt's measured range, so it tracks scrolling;
// the stored anchor only covers the commit before the range is measured.
- const annotationBox = quoteMarkRangesRef.current
- .find((mark) => mark.index === annotationIndex)
+ const annotationBox = quoteRangesRef.current
+ .find((found) => found.index === annotationIndex)
?.range.getBoundingClientRect();
- const annotationAnchor = annotationBox
- ? { x: annotationBox.left + annotationBox.width / 2, y: annotationBox.top }
- : quoteAnnotation?.anchor;
+ const annotationAnchor = annotationBox ? excerptAnchor(annotationBox) : quoteAnnotation?.anchor;
+ const annotationBelow =
+ annotationAnchor !== undefined &&
+ annotationAnchor.bottom + 8 + QUOTE_ANNOTATION_PANEL_HEIGHT <= window.innerHeight - 8;
if (!props.activeSession) {
const conversationItems = props.conversationItems ?? [];
@@ -1142,30 +1112,13 @@ export function ChatView(props: {
Math.max(annotationAnchor.x, QUOTE_ANNOTATION_PANEL_HALF),
Math.max(QUOTE_ANNOTATION_PANEL_HALF, window.innerWidth - QUOTE_ANNOTATION_PANEL_HALF),
),
- y: Math.min(
- Math.max(8, annotationAnchor.y + 12),
- Math.max(8, window.innerHeight - QUOTE_ANNOTATION_PANEL_HEIGHT),
- ),
- style: { transform: 'translateX(-50%)' },
+ y: annotationBelow
+ ? Math.max(8, annotationAnchor.bottom + 8)
+ : Math.max(8 + QUOTE_ANNOTATION_PANEL_HEIGHT, annotationAnchor.top - 8),
+ style: { transform: annotationBelow ? 'translateX(-50%)' : 'translate(-50%, -100%)' },
},
)
: null}
- {quoteMarks.length > 0
- ? createPortal(
- <>
- {quoteMarks.map((mark) => (
- <Badge
- key={mark.index}
- variant="info"
- label={mark.index + 1}
- className="maka-quote-ordinal"
- style={{ left: mark.x, top: mark.y }}
- />
- ))}
- </>,
- document.body,
- )
- : null}
{barVisible
? selectionActionsLayer.render(
<div
@@ -1186,14 +1139,19 @@ export function ChatView(props: {
<Button
type="button"
label={copy.quoteSelection}
- onClick={() =>
+ onClick={() => {
+ const selection = window.getSelection();
setQuoteAnnotation({
kind: 'annotate',
text: selectionQuote.text,
turnId: selectionQuote.turnId,
- anchor: selectionQuote.anchor,
- })
- }
+ anchor: excerptAnchor(
+ selection?.rangeCount
+ ? selection.getRangeAt(0).getBoundingClientRect()
+ : new DOMRect(selectionQuote.anchor.x, selectionQuote.anchor.y),
+ ),
+ });
+ }}
/>
<Button
type="button"
diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css
index 95f4151ad1..a8c82f10de 100644
--- a/packages/ui/src/styles.css
+++ b/packages/ui/src/styles.css
@@ -188,9 +188,6 @@
.maka-quote-hover-card { max-width: 360px; }
.maka-quote-comment-panel { width: 320px; max-width: 100%; }
-/* Ported to the viewport so a transformed ancestor cannot move the containing
- block; display-only, so it must never catch a click or the selection. */
-.maka-quote-ordinal { position: fixed; z-index: var(--z-sticky, 20); transform: translate(-50%, -110%); pointer-events: none; }
/* Painted rather than selected: the note input takes the DOM selection. */
::highlight(maka-quote-mark) { background-color: var(--selection); }
diff --git a/packages/ui/stories/quote-comment.stories.tsx b/packages/ui/stories/quote-comment.stories.tsx
index 531c3ea771..8e1c908ed5 100644
--- a/packages/ui/stories/quote-comment.stories.tsx
+++ b/packages/ui/stories/quote-comment.stories.tsx
@@ -255,33 +255,33 @@ async function typeNote(panel: HTMLElement, note: string) {
await userEvent.type(field!, note);
}
-/** Ordinals pinned at their excerpts' ends: every staged quote keeps one, and
- * the note being written borrows the next slot's number. `total` is the count
- * of pins that should be on the transcript, so a pin quietly dropping is as
- * much a failure as a wrong label. */
-async function expectOrdinal(label: string, total: number) {
- const badges = await waitFor(() => {
- const all = [...document.querySelectorAll<HTMLElement>('.maka-quote-ordinal')].filter(
- (candidate) => candidate.checkVisibility(),
+/** Exactly these excerpts are painted: every staged quote's, plus the one a
+ * note is being written on. A mark quietly dropping fails as much as an
+ * extra one. */
+async function expectPainted(...excerpts: string[]) {
+ await waitFor(() => {
+ const painted = [...(CSS.highlights?.get('maka-quote-mark') ?? [])].map((range) =>
+ (range as Range).toString().replace(/\s+/g, ' ').trim(),
);
- expect(all.length).toBe(total);
- return all;
+ expect(painted.sort()).toEqual([...excerpts].sort());
+ });
+}
+
+/** The note panel hangs just below its own excerpt — or just above it when
+ * the window has no room below — and never covers the text it annotates. */
+async function expectPanelAt(panel: HTMLElement, excerpt: string) {
+ await waitFor(() => {
+ const range = [...(CSS.highlights?.get('maka-quote-mark') ?? [])].find(
+ (candidate) => (candidate as Range).toString().replace(/\s+/g, ' ').trim() === excerpt,
+ ) as Range | undefined;
+ expect(range).toBeTruthy();
+ const text = range!.getBoundingClientRect();
+ const box = panel.getBoundingClientRect();
+ const gap = box.top >= text.bottom ? box.top - text.bottom : text.top - box.bottom;
+ expect(gap).toBeGreaterThanOrEqual(0);
+ expect(gap).toBeLessThan(40);
+ expect(Math.abs(box.left + box.width / 2 - (text.left + text.width / 2))).toBeLessThan(48);
});
- const badge = badges.find((candidate) => candidate.textContent?.trim() === label);
- expect(badge).toBeTruthy();
- // A pin belongs at its own excerpt's end: it must hover just above where
- // one of the painted ranges finishes, so it never covers the text it marks.
- const ends: { x: number; y: number }[] = [];
- for (const range of CSS.highlights?.get('maka-quote-mark') ?? []) {
- const rects = (range as Range).getClientRects();
- const last = rects[rects.length - 1];
- if (last) ends.push({ x: last.right, y: last.top });
- }
- const box = (badge as HTMLElement).getBoundingClientRect();
- const centerX = box.left + box.width / 2;
- expect(
- ends.some((end) => Math.abs(centerX - end.x) < 26 && Math.abs(box.bottom - end.y) < 14),
- ).toBe(true);
}
async function visiblePanel(): Promise<HTMLElement> {
@@ -366,10 +366,9 @@ export const TranscriptQuoteGesture: Story = {
expect(el).toBeTruthy();
return el as HTMLElement;
});
- // Submitting keeps the mark: the staged excerpt stays highlighted with
- // its ordinal pinned at the end, so the transcript still shows what was
- // quoted.
- await expectOrdinal('1', 1);
+ // Submitting keeps the mark: the staged excerpt stays highlighted, so the
+ // transcript still shows what was quoted.
+ await expectPainted(ASSISTANT_REPLY);
// The token's editor anchors back at the excerpt — the transcript's
// layer, not the token's popover — with the note prefilled.
await userEvent.click(token);
@@ -413,6 +412,8 @@ export const TranscriptQuoteGesture: Story = {
},
};
+const FIRST_EXCERPT = '第 6 次被 Vercel 的免费层限流拦截';
+const SECOND_EXCERPT = '核对限流规则,再判断是否能降速继续';
const SECOND_REPLY =
'第二轮我会按 debug 技能核对限流规则,再判断是否能降速继续,并补一轮端到端验证。';
const SECOND_TURN: StoredMessage = {
@@ -424,10 +425,8 @@ const SECOND_TURN: StoredMessage = {
modelId: 'claude-sonnet-4-5',
};
-// Real path, several annotations at once: each fresh note is numbered with
-// the slot it will take, and each staged token's editor reopens at its own
-// excerpt carrying that quote's own ordinal — the marker sits on the text,
-// not on the card.
+// Real path, several annotations at once: every staged excerpt stays
+// highlighted, and each token's editor reopens at its own excerpt.
export const TranscriptTwoAnnotations: Story = {
render: () => (
<Frame>
@@ -437,51 +436,47 @@ export const TranscriptTwoAnnotations: Story = {
</Frame>
),
play: async () => {
- // First annotation on the earlier reply takes the first free slot.
- await selectExcerpt('turn-3', '第 6 次被 Vercel 的免费层限流拦截');
+ await selectExcerpt('turn-3', FIRST_EXCERPT);
await userEvent.click(await quoteActionButton());
const firstPanel = await visiblePanel();
- await expectOrdinal('1', 1);
+ await expectPainted(FIRST_EXCERPT);
await typeNote(firstPanel, '限流这段先核');
// Enter submits the note like the panel's 引用 button.
await userEvent.keyboard('{Enter}');
// Staging the first token re-renders the transcript (pendingQuotes); the
- // submitted excerpt keeps its highlight and pin instead of losing the
- // mark with the panel. Select the next excerpt only once that churn has
+ // submitted excerpt keeps its highlight instead of losing the mark with
+ // the panel. Select the next excerpt only once that churn has
// landed, or the new range collapses on the text nodes it replaces.
await waitFor(() =>
expect(document.querySelectorAll('.maka-composer-quote-token').length).toBe(1),
);
- await expectOrdinal('1', 1);
+ await expectPainted(FIRST_EXCERPT);
- // The second annotation is numbered by what is already staged, and the
- // first pin stays put while it is written.
- await selectExcerpt('turn-4', '核对限流规则,再判断是否能降速继续');
+ // The first mark stays while the second note is written.
+ await selectExcerpt('turn-4', SECOND_EXCERPT);
await userEvent.click(await quoteActionButton());
const secondPanel = await visiblePanel();
- await expectOrdinal('2', 2);
+ await expectPainted(FIRST_EXCERPT, SECOND_EXCERPT);
await userEvent.click(panelButton(secondPanel, '引用'));
- // Both staged; each excerpt keeps its own pin and each token's editor
- // anchors back at its own excerpt, so which staged quote it is never
- // ambiguous.
+ // Both staged; each token's editor anchors back at its own excerpt, so
+ // which staged quote it is never ambiguous.
await waitFor(() => {
const all = document.querySelectorAll<HTMLElement>('.maka-composer-quote-token');
expect(all.length).toBe(2);
});
- await expectOrdinal('1', 2);
- await expectOrdinal('2', 2);
+ await expectPainted(FIRST_EXCERPT, SECOND_EXCERPT);
const tokens = [...document.querySelectorAll<HTMLElement>('.maka-composer-quote-token')];
await userEvent.click(tokens[0]);
const firstEdit = await visiblePanel();
- await expectOrdinal('1', 2);
+ await expectPanelAt(firstEdit, FIRST_EXCERPT);
expect(firstEdit.closest('.maka-quote-annotation-layer')).toBeTruthy();
expect(firstEdit.querySelector('[contenteditable="true"]')?.textContent).toBe('限流这段先核');
await waitFor(() => expect(firstEdit.contains(document.activeElement)).toBe(true));
await userEvent.click(panelButton(firstEdit, '取消'));
await userEvent.click(tokens[1]);
const secondEdit = await visiblePanel();
- await expectOrdinal('2', 2);
+ await expectPanelAt(secondEdit, SECOND_EXCERPT);
expect(secondEdit.closest('.maka-quote-annotation-layer')).toBeTruthy();
},
};
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org