repoview 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/public/app.css +778 -0
- package/public/app.js +70 -0
- package/public/review.js +584 -0
- package/src/cli.js +18 -0
- package/src/markdown.js +157 -3
- package/src/review-cli.js +245 -0
- package/src/server.js +366 -0
- package/src/views.js +178 -0
package/src/server.js
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
renderDiffPage,
|
|
19
19
|
renderErrorPage,
|
|
20
20
|
renderFilePage,
|
|
21
|
+
renderReviewListPage,
|
|
22
|
+
renderReviewThreadPage,
|
|
21
23
|
renderTreePage,
|
|
22
24
|
} from "./views.js";
|
|
23
25
|
|
|
@@ -700,6 +702,369 @@ export async function startServer({ repoRoot, host, port, watch }) {
|
|
|
700
702
|
}
|
|
701
703
|
});
|
|
702
704
|
|
|
705
|
+
// --- Review routes ---
|
|
706
|
+
const reviewDir = path.join(repoRootReal, ".repoview", "reviews");
|
|
707
|
+
const THREAD_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
708
|
+
|
|
709
|
+
function validateThreadId(id) {
|
|
710
|
+
return id && typeof id === "string" && THREAD_ID_RE.test(id);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
app.get("/review/", async (req, res) => {
|
|
714
|
+
try {
|
|
715
|
+
let entries = [];
|
|
716
|
+
try {
|
|
717
|
+
entries = await fs.readdir(reviewDir, { withFileTypes: true });
|
|
718
|
+
} catch {
|
|
719
|
+
// no review dir yet
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const threads = [];
|
|
723
|
+
for (const entry of entries) {
|
|
724
|
+
if (!entry.isDirectory()) continue;
|
|
725
|
+
const threadFile = path.join(reviewDir, entry.name, "thread.json");
|
|
726
|
+
try {
|
|
727
|
+
const thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
|
|
728
|
+
let messageCount = 0;
|
|
729
|
+
let lastMessageId = null;
|
|
730
|
+
try {
|
|
731
|
+
const msgs = (await fs.readdir(path.join(reviewDir, entry.name, "messages")))
|
|
732
|
+
.filter((f) => f.endsWith(".json"))
|
|
733
|
+
.sort();
|
|
734
|
+
messageCount = msgs.length;
|
|
735
|
+
lastMessageId = msgs.length ? msgs[msgs.length - 1].replace(".json", "") : null;
|
|
736
|
+
} catch {
|
|
737
|
+
// no messages
|
|
738
|
+
}
|
|
739
|
+
const unreadCount = thread.readUntil && lastMessageId
|
|
740
|
+
? Math.max(0, parseInt(lastMessageId, 10) - parseInt(thread.readUntil, 10))
|
|
741
|
+
: thread.readUntil ? 0 : messageCount;
|
|
742
|
+
threads.push({ ...thread, messageCount, lastMessageId, unreadCount });
|
|
743
|
+
} catch {
|
|
744
|
+
// skip
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
threads.sort((a, b) => new Date(b.lastActivityAt) - new Date(a.lastActivityAt));
|
|
749
|
+
|
|
750
|
+
res.status(200).send(
|
|
751
|
+
renderReviewListPage({
|
|
752
|
+
title: `${repoName} · Reviews`,
|
|
753
|
+
repoName,
|
|
754
|
+
gitInfo,
|
|
755
|
+
threads,
|
|
756
|
+
}),
|
|
757
|
+
);
|
|
758
|
+
} catch (e) {
|
|
759
|
+
res.status(500).send(renderErrorPage({ title: "Error", message: e.message }));
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
app.get("/review/:threadId", async (req, res) => {
|
|
764
|
+
try {
|
|
765
|
+
const { threadId } = req.params;
|
|
766
|
+
if (!validateThreadId(threadId)) {
|
|
767
|
+
return res.status(400).send(renderErrorPage({ title: "Error", message: "Invalid thread ID" }));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const threadDir = path.join(reviewDir, threadId);
|
|
771
|
+
const threadFile = path.join(threadDir, "thread.json");
|
|
772
|
+
|
|
773
|
+
let thread;
|
|
774
|
+
try {
|
|
775
|
+
thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
|
|
776
|
+
} catch {
|
|
777
|
+
return res.status(404).send(renderErrorPage({ title: "Error", message: "Thread not found" }));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const messagesDir = path.join(threadDir, "messages");
|
|
781
|
+
let messageFiles = [];
|
|
782
|
+
try {
|
|
783
|
+
messageFiles = (await fs.readdir(messagesDir)).filter((f) => f.endsWith(".json")).sort();
|
|
784
|
+
} catch {
|
|
785
|
+
// no messages
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const messages = [];
|
|
789
|
+
for (const f of messageFiles) {
|
|
790
|
+
messages.push(JSON.parse(await fs.readFile(path.join(messagesDir, f), "utf8")));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
let comments = [];
|
|
794
|
+
try {
|
|
795
|
+
const commentsData = JSON.parse(await fs.readFile(path.join(threadDir, "comments.json"), "utf8"));
|
|
796
|
+
comments = commentsData.comments || [];
|
|
797
|
+
} catch {
|
|
798
|
+
// no comments
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Render agent messages as markdown, user messages as plain text
|
|
802
|
+
const renderedMessages = messages.map((msg) => {
|
|
803
|
+
if (msg.role === "agent" && msg.format === "markdown") {
|
|
804
|
+
return md.render(msg.body, { baseDirPosix: "", emitLineMap: true });
|
|
805
|
+
}
|
|
806
|
+
return msg.body;
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Mark as read
|
|
810
|
+
if (messageFiles.length) {
|
|
811
|
+
const lastMsgId = messageFiles[messageFiles.length - 1].replace(".json", "");
|
|
812
|
+
thread.readUntil = lastMsgId;
|
|
813
|
+
await fs.writeFile(threadFile, JSON.stringify(thread, null, 2) + "\n");
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
res.status(200).send(
|
|
817
|
+
renderReviewThreadPage({
|
|
818
|
+
title: `${repoName} · ${thread.title}`,
|
|
819
|
+
repoName,
|
|
820
|
+
gitInfo,
|
|
821
|
+
thread,
|
|
822
|
+
messages,
|
|
823
|
+
comments,
|
|
824
|
+
renderedMessages,
|
|
825
|
+
}),
|
|
826
|
+
);
|
|
827
|
+
} catch (e) {
|
|
828
|
+
res.status(500).send(renderErrorPage({ title: "Error", message: e.message }));
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
app.post("/review/:threadId/messages", express.json(), async (req, res) => {
|
|
833
|
+
try {
|
|
834
|
+
const { threadId } = req.params;
|
|
835
|
+
if (!validateThreadId(threadId)) {
|
|
836
|
+
return res.status(400).json({ error: "Invalid thread ID" });
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const threadDir = path.join(reviewDir, threadId);
|
|
840
|
+
const threadFile = path.join(threadDir, "thread.json");
|
|
841
|
+
const messagesDir = path.join(threadDir, "messages");
|
|
842
|
+
|
|
843
|
+
let thread;
|
|
844
|
+
try {
|
|
845
|
+
thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
|
|
846
|
+
} catch {
|
|
847
|
+
return res.status(404).json({ error: "Thread not found" });
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const { body } = req.body;
|
|
851
|
+
if (!body || !body.trim()) {
|
|
852
|
+
return res.status(400).json({ error: "Message body is required" });
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
let entries = [];
|
|
856
|
+
try {
|
|
857
|
+
entries = (await fs.readdir(messagesDir)).filter((f) => f.endsWith(".json"));
|
|
858
|
+
} catch {
|
|
859
|
+
await fs.mkdir(messagesDir, { recursive: true });
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const existingIds = entries.map((e) => e.replace(".json", ""));
|
|
863
|
+
let max = 0;
|
|
864
|
+
for (const id of existingIds) {
|
|
865
|
+
const n = parseInt(id, 10);
|
|
866
|
+
if (n > max) max = n;
|
|
867
|
+
}
|
|
868
|
+
const nextId = String(max + 1).padStart(3, "0");
|
|
869
|
+
const now = new Date().toISOString();
|
|
870
|
+
|
|
871
|
+
const message = {
|
|
872
|
+
id: nextId,
|
|
873
|
+
role: "user",
|
|
874
|
+
format: "text",
|
|
875
|
+
body: body.trim(),
|
|
876
|
+
createdAt: now,
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
await fs.writeFile(path.join(messagesDir, `${nextId}.json`), JSON.stringify(message, null, 2) + "\n");
|
|
880
|
+
thread.lastActivityAt = now;
|
|
881
|
+
await fs.writeFile(threadFile, JSON.stringify(thread, null, 2) + "\n");
|
|
882
|
+
|
|
883
|
+
res.status(201).json(message);
|
|
884
|
+
} catch (e) {
|
|
885
|
+
res.status(500).json({ error: e.message });
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
app.post("/review/:threadId/comments", express.json(), async (req, res) => {
|
|
890
|
+
try {
|
|
891
|
+
const { threadId } = req.params;
|
|
892
|
+
if (!validateThreadId(threadId)) {
|
|
893
|
+
return res.status(400).json({ error: "Invalid thread ID" });
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const threadDir = path.join(reviewDir, threadId);
|
|
897
|
+
const commentsFile = path.join(threadDir, "comments.json");
|
|
898
|
+
|
|
899
|
+
const { messageId, anchorLine, anchorEndLine, anchorText, body } = req.body;
|
|
900
|
+
if (!body || !body.trim()) {
|
|
901
|
+
return res.status(400).json({ error: "Comment body is required" });
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
let commentsData = { comments: [] };
|
|
905
|
+
try {
|
|
906
|
+
commentsData = JSON.parse(await fs.readFile(commentsFile, "utf8"));
|
|
907
|
+
} catch {
|
|
908
|
+
// fresh comments
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const id = `c_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`;
|
|
912
|
+
const comment = {
|
|
913
|
+
id,
|
|
914
|
+
messageId: messageId || null,
|
|
915
|
+
anchorLine: anchorLine || null,
|
|
916
|
+
anchorEndLine: anchorEndLine || null,
|
|
917
|
+
anchorText: anchorText || null,
|
|
918
|
+
body: body.trim(),
|
|
919
|
+
createdAt: new Date().toISOString(),
|
|
920
|
+
resolved: false,
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
commentsData.comments.push(comment);
|
|
924
|
+
await fs.writeFile(commentsFile, JSON.stringify(commentsData, null, 2) + "\n");
|
|
925
|
+
|
|
926
|
+
res.status(201).json(comment);
|
|
927
|
+
} catch (e) {
|
|
928
|
+
res.status(500).json({ error: e.message });
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
app.patch("/review/:threadId/comments/:commentId", express.json(), async (req, res) => {
|
|
933
|
+
try {
|
|
934
|
+
const { threadId, commentId } = req.params;
|
|
935
|
+
if (!validateThreadId(threadId)) {
|
|
936
|
+
return res.status(400).json({ error: "Invalid thread ID" });
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const commentsFile = path.join(reviewDir, threadId, "comments.json");
|
|
940
|
+
|
|
941
|
+
let commentsData;
|
|
942
|
+
try {
|
|
943
|
+
commentsData = JSON.parse(await fs.readFile(commentsFile, "utf8"));
|
|
944
|
+
} catch {
|
|
945
|
+
return res.status(404).json({ error: "Comments not found" });
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const comment = commentsData.comments.find((c) => c.id === commentId);
|
|
949
|
+
if (!comment) {
|
|
950
|
+
return res.status(404).json({ error: "Comment not found" });
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (req.body.resolved !== undefined) comment.resolved = req.body.resolved;
|
|
954
|
+
if (req.body.body !== undefined) comment.body = req.body.body;
|
|
955
|
+
|
|
956
|
+
await fs.writeFile(commentsFile, JSON.stringify(commentsData, null, 2) + "\n");
|
|
957
|
+
res.status(200).json(comment);
|
|
958
|
+
} catch (e) {
|
|
959
|
+
res.status(500).json({ error: e.message });
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
app.delete("/review/:threadId/comments/:commentId", async (req, res) => {
|
|
964
|
+
try {
|
|
965
|
+
const { threadId, commentId } = req.params;
|
|
966
|
+
if (!validateThreadId(threadId)) {
|
|
967
|
+
return res.status(400).json({ error: "Invalid thread ID" });
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const commentsFile = path.join(reviewDir, threadId, "comments.json");
|
|
971
|
+
|
|
972
|
+
let commentsData;
|
|
973
|
+
try {
|
|
974
|
+
commentsData = JSON.parse(await fs.readFile(commentsFile, "utf8"));
|
|
975
|
+
} catch {
|
|
976
|
+
return res.status(404).json({ error: "Comments not found" });
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const idx = commentsData.comments.findIndex((c) => c.id === commentId);
|
|
980
|
+
if (idx === -1) {
|
|
981
|
+
return res.status(404).json({ error: "Comment not found" });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
commentsData.comments.splice(idx, 1);
|
|
985
|
+
await fs.writeFile(commentsFile, JSON.stringify(commentsData, null, 2) + "\n");
|
|
986
|
+
res.status(200).json({ ok: true });
|
|
987
|
+
} catch (e) {
|
|
988
|
+
res.status(500).json({ error: e.message });
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
app.post("/review/:threadId/mark-read", express.json(), async (req, res) => {
|
|
993
|
+
try {
|
|
994
|
+
const { threadId } = req.params;
|
|
995
|
+
if (!validateThreadId(threadId)) {
|
|
996
|
+
return res.status(400).json({ error: "Invalid thread ID" });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const threadFile = path.join(reviewDir, threadId, "thread.json");
|
|
1000
|
+
let thread;
|
|
1001
|
+
try {
|
|
1002
|
+
thread = JSON.parse(await fs.readFile(threadFile, "utf8"));
|
|
1003
|
+
} catch {
|
|
1004
|
+
return res.status(404).json({ error: "Thread not found" });
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const { readUntil } = req.body;
|
|
1008
|
+
if (readUntil) thread.readUntil = readUntil;
|
|
1009
|
+
await fs.writeFile(threadFile, JSON.stringify(thread, null, 2) + "\n");
|
|
1010
|
+
res.status(200).json({ ok: true });
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
res.status(500).json({ error: e.message });
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
// --- Code context API for inline code popups ---
|
|
1017
|
+
app.get("/api/code-context", async (req, res) => {
|
|
1018
|
+
try {
|
|
1019
|
+
const filePath = req.query.file;
|
|
1020
|
+
if (!filePath || typeof filePath !== "string") {
|
|
1021
|
+
return res.status(400).json({ error: "file parameter required" });
|
|
1022
|
+
}
|
|
1023
|
+
const line = parseInt(req.query.line, 10) || 1;
|
|
1024
|
+
const endLine = parseInt(req.query.endLine, 10) || line;
|
|
1025
|
+
const context = Math.min(parseInt(req.query.context, 10) || 20, 200);
|
|
1026
|
+
|
|
1027
|
+
const { resolved, stripped } = await safeRealpath(repoRootReal, filePath);
|
|
1028
|
+
const st = await statSafe(resolved);
|
|
1029
|
+
if (!st || !st.isFile) {
|
|
1030
|
+
return res.status(404).json({ error: "File not found" });
|
|
1031
|
+
}
|
|
1032
|
+
if (st.size > 2 * 1024 * 1024) {
|
|
1033
|
+
return res.status(400).json({ error: "File too large" });
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const raw = await fs.readFile(resolved, "utf8");
|
|
1037
|
+
const allLines = raw.split("\n");
|
|
1038
|
+
|
|
1039
|
+
const startLine = Math.max(1, Math.min(line, endLine) - context);
|
|
1040
|
+
const stopLine = Math.min(allLines.length, Math.max(line, endLine) + context);
|
|
1041
|
+
const snippet = allLines.slice(startLine - 1, stopLine);
|
|
1042
|
+
|
|
1043
|
+
const ext = path.extname(stripped).slice(1);
|
|
1044
|
+
|
|
1045
|
+
// Get diff for this file (against HEAD)
|
|
1046
|
+
let diff = null;
|
|
1047
|
+
const diffResult = await execGit(repoRootReal, ["diff", "HEAD", "--", stripped], 256 * 1024);
|
|
1048
|
+
if (diffResult.output) {
|
|
1049
|
+
diff = diffResult.output;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
res.json({
|
|
1053
|
+
file: toPosixPath(stripped),
|
|
1054
|
+
startLine,
|
|
1055
|
+
stopLine,
|
|
1056
|
+
highlightStart: Math.min(line, endLine),
|
|
1057
|
+
highlightEnd: Math.max(line, endLine),
|
|
1058
|
+
lines: snippet,
|
|
1059
|
+
language: ext,
|
|
1060
|
+
totalLines: allLines.length,
|
|
1061
|
+
diff,
|
|
1062
|
+
});
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
|
|
703
1068
|
app.get(["/raw/*", "/raw"], async (req, res) => {
|
|
704
1069
|
try {
|
|
705
1070
|
const p = req.params[0] ?? "";
|
|
@@ -734,6 +1099,7 @@ export async function startServer({ repoRoot, host, port, watch }) {
|
|
|
734
1099
|
ignored: [
|
|
735
1100
|
/(^|[/\\])\.git([/\\]|$)/,
|
|
736
1101
|
/(^|[/\\])node_modules([/\\]|$)/,
|
|
1102
|
+
/(^|[/\\])\.repoview([/\\]|$)/,
|
|
737
1103
|
],
|
|
738
1104
|
ignoreInitial: true,
|
|
739
1105
|
ignorePermissionErrors: true,
|
package/src/views.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
+
function formatReviewTime(isoString) {
|
|
4
|
+
if (!isoString) return "";
|
|
5
|
+
const d = new Date(isoString);
|
|
6
|
+
const now = Date.now();
|
|
7
|
+
const diff = now - d.getTime();
|
|
8
|
+
if (diff < 60000) return "just now";
|
|
9
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
10
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
11
|
+
if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`;
|
|
12
|
+
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
export function escapeHtml(s) {
|
|
4
16
|
return String(s)
|
|
5
17
|
.replaceAll("&", "&")
|
|
@@ -111,6 +123,7 @@ function renderMetaMenu({ gitInfo, brokenLinks, querySuffix, toggleIgnoredHref,
|
|
|
111
123
|
<summary class="pill link" aria-label="More">More</summary>
|
|
112
124
|
<div class="menu-panel" role="menu">
|
|
113
125
|
<a class="menu-item link" href="${diffHref}" role="menuitem">Diff view</a>
|
|
126
|
+
<a class="menu-item link" href="/review/" role="menuitem">Reviews</a>
|
|
114
127
|
<a class="menu-item link" href="${brokenHref}" role="menuitem">${escapeHtml(brokenLabel)}</a>
|
|
115
128
|
<a class="menu-item link" data-no-preserve="ignored" href="${ignoredHref}" role="menuitem">${escapeHtml(
|
|
116
129
|
ignoredLabel,
|
|
@@ -157,6 +170,7 @@ function pageTemplateWithLinks({
|
|
|
157
170
|
${commit ? `<span class="pill mono meta-commit">${commit}</span>` : ""}
|
|
158
171
|
<span class="meta-actions">
|
|
159
172
|
<a class="pill link" href="/diff${querySuffix || ""}">Diff</a>
|
|
173
|
+
<a class="pill link" href="/review/">Review</a>
|
|
160
174
|
${brokenPill}
|
|
161
175
|
${ignoredPill}
|
|
162
176
|
</span>
|
|
@@ -377,6 +391,170 @@ export function renderDiffPage({
|
|
|
377
391
|
</html>`;
|
|
378
392
|
}
|
|
379
393
|
|
|
394
|
+
export function renderReviewListPage({
|
|
395
|
+
title,
|
|
396
|
+
repoName,
|
|
397
|
+
gitInfo,
|
|
398
|
+
threads,
|
|
399
|
+
}) {
|
|
400
|
+
const branch = gitInfo?.branch ? escapeHtml(gitInfo.branch) : "no-git";
|
|
401
|
+
const commit = gitInfo?.commit ? escapeHtml(gitInfo.commit.slice(0, 7)) : "";
|
|
402
|
+
|
|
403
|
+
const threadRows = threads.length
|
|
404
|
+
? threads
|
|
405
|
+
.map((t) => {
|
|
406
|
+
const unread = t.readUntil
|
|
407
|
+
? t.lastMessageId && t.lastMessageId > t.readUntil
|
|
408
|
+
: t.messageCount > 0;
|
|
409
|
+
const badge = unread ? `<span class="review-unread-badge">${t.unreadCount || "new"}</span>` : "";
|
|
410
|
+
const timeAgo = escapeHtml(formatReviewTime(t.lastActivityAt || t.createdAt));
|
|
411
|
+
return `<a class="review-thread-row" href="/review/${encodeURIComponent(t.id)}">
|
|
412
|
+
<div class="review-thread-info">
|
|
413
|
+
<span class="review-thread-title">${escapeHtml(t.title)}${badge}</span>
|
|
414
|
+
<span class="review-thread-meta">${t.messageCount} message${t.messageCount !== 1 ? "s" : ""} · ${timeAgo}</span>
|
|
415
|
+
</div>
|
|
416
|
+
<span class="review-thread-arrow">›</span>
|
|
417
|
+
</a>`;
|
|
418
|
+
})
|
|
419
|
+
.join("\n")
|
|
420
|
+
: `<div class="review-empty">No review threads yet.</div>`;
|
|
421
|
+
|
|
422
|
+
const body = `<section class="panel">
|
|
423
|
+
<div class="panel-title">Review Threads</div>
|
|
424
|
+
<div class="review-thread-list">
|
|
425
|
+
${threadRows}
|
|
426
|
+
</div>
|
|
427
|
+
</section>`;
|
|
428
|
+
|
|
429
|
+
return `<!doctype html>
|
|
430
|
+
<html lang="en">
|
|
431
|
+
<head>
|
|
432
|
+
<meta charset="utf-8" />
|
|
433
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
434
|
+
<title>${escapeHtml(title)}</title>
|
|
435
|
+
<link rel="stylesheet" href="/static/app.css" />
|
|
436
|
+
</head>
|
|
437
|
+
<body>
|
|
438
|
+
<header class="topbar">
|
|
439
|
+
<div class="topbar-row">
|
|
440
|
+
<a class="brand" href="/tree/">${escapeHtml(repoName)}</a>
|
|
441
|
+
<div class="meta">
|
|
442
|
+
<span class="pill">${branch}</span>
|
|
443
|
+
${commit ? `<span class="pill mono">${commit}</span>` : ""}
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
</header>
|
|
447
|
+
<main class="container">
|
|
448
|
+
${body}
|
|
449
|
+
</main>
|
|
450
|
+
<script type="module" src="/static/app.js"></script>
|
|
451
|
+
</body>
|
|
452
|
+
</html>`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function renderCommentCard(c, msgId) {
|
|
456
|
+
return `<div class="review-comment-card${c.resolved ? " resolved" : ""}" data-comment-id="${escapeHtml(c.id)}" data-message-id="${escapeHtml(msgId)}" data-anchor-line="${c.anchorLine || ""}" data-anchor-end-line="${c.anchorEndLine || ""}">
|
|
457
|
+
<div class="review-comment-header">
|
|
458
|
+
<span class="review-comment-anchor">${c.anchorLine ? `Line ${c.anchorLine}${c.anchorEndLine && c.anchorEndLine !== c.anchorLine ? `-${c.anchorEndLine}` : ""}` : "General"}</span>
|
|
459
|
+
<span class="review-comment-time" title="${escapeHtml(c.createdAt)}">${escapeHtml(formatReviewTime(c.createdAt))}</span>
|
|
460
|
+
<span class="review-comment-actions">
|
|
461
|
+
${!c.resolved ? `<button class="btn btn-sm review-resolve-btn" data-comment-id="${escapeHtml(c.id)}">Resolve</button>` : `<span class="review-resolved-label">Resolved</span>`}
|
|
462
|
+
<button class="btn btn-sm review-delete-comment-btn" data-comment-id="${escapeHtml(c.id)}">Delete</button>
|
|
463
|
+
</span>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="review-comment-body">${escapeHtml(c.body)}</div>
|
|
466
|
+
</div>`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function renderReviewThreadPage({
|
|
470
|
+
title,
|
|
471
|
+
repoName,
|
|
472
|
+
gitInfo,
|
|
473
|
+
thread,
|
|
474
|
+
messages,
|
|
475
|
+
comments,
|
|
476
|
+
renderedMessages,
|
|
477
|
+
}) {
|
|
478
|
+
const branch = gitInfo?.branch ? escapeHtml(gitInfo.branch) : "no-git";
|
|
479
|
+
const commit = gitInfo?.commit ? escapeHtml(gitInfo.commit.slice(0, 7)) : "";
|
|
480
|
+
|
|
481
|
+
const messageBlocks = messages
|
|
482
|
+
.map((msg, idx) => {
|
|
483
|
+
const isAgent = msg.role === "agent";
|
|
484
|
+
const roleClass = isAgent ? "review-msg-agent" : "review-msg-user";
|
|
485
|
+
const roleLabel = isAgent ? "Agent" : "You";
|
|
486
|
+
const rendered = renderedMessages[idx];
|
|
487
|
+
|
|
488
|
+
// Gather comments for this message
|
|
489
|
+
const msgComments = comments.filter((c) => c.messageId === msg.id);
|
|
490
|
+
const commentHtml = msgComments.length
|
|
491
|
+
? `<div class="review-inline-comments">${msgComments.map((c) => renderCommentCard(c, msg.id)).join("\n")}</div>`
|
|
492
|
+
: "";
|
|
493
|
+
|
|
494
|
+
const contentWrapper = isAgent
|
|
495
|
+
? `<div class="markdown-body markdown-wrap review-msg-content" data-message-id="${escapeHtml(msg.id)}">${rendered}</div>`
|
|
496
|
+
: `<div class="review-msg-content review-msg-text" data-message-id="${escapeHtml(msg.id)}">${escapeHtml(rendered)}</div>`;
|
|
497
|
+
|
|
498
|
+
return `<div class="review-message ${roleClass}" data-message-id="${escapeHtml(msg.id)}">
|
|
499
|
+
<div class="review-msg-header">
|
|
500
|
+
<span class="review-msg-role">${roleLabel}</span>
|
|
501
|
+
<span class="review-msg-time" title="${escapeHtml(msg.createdAt)}">${escapeHtml(formatReviewTime(msg.createdAt))}</span>
|
|
502
|
+
</div>
|
|
503
|
+
${contentWrapper}
|
|
504
|
+
${commentHtml}
|
|
505
|
+
</div>`;
|
|
506
|
+
})
|
|
507
|
+
.join("\n");
|
|
508
|
+
|
|
509
|
+
const body = `<section class="panel review-thread-panel">
|
|
510
|
+
<div class="panel-title review-thread-header">
|
|
511
|
+
<a class="btn" href="/review/">← Back</a>
|
|
512
|
+
<span class="review-thread-title-text">${escapeHtml(thread.title)}</span>
|
|
513
|
+
<span class="spacer"></span>
|
|
514
|
+
</div>
|
|
515
|
+
<div class="review-messages">
|
|
516
|
+
${messageBlocks || `<div class="review-empty">No messages yet.</div>`}
|
|
517
|
+
</div>
|
|
518
|
+
<div class="review-reply-form">
|
|
519
|
+
<textarea id="review-reply-text" class="review-reply-textarea" placeholder="Write a reply..." rows="4"></textarea>
|
|
520
|
+
<button id="review-reply-submit" class="btn review-reply-btn" data-thread-id="${escapeHtml(thread.id)}">Submit Reply</button>
|
|
521
|
+
</div>
|
|
522
|
+
</section>`;
|
|
523
|
+
|
|
524
|
+
return `<!doctype html>
|
|
525
|
+
<html lang="en">
|
|
526
|
+
<head>
|
|
527
|
+
<meta charset="utf-8" />
|
|
528
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
529
|
+
<title>${escapeHtml(title)}</title>
|
|
530
|
+
<link rel="stylesheet" href="/static/vendor/github-markdown-css/github-markdown.css" />
|
|
531
|
+
<link rel="stylesheet" href="/static/vendor/highlight.js/styles/github.css" media="(prefers-color-scheme: light)" />
|
|
532
|
+
<link rel="stylesheet" href="/static/vendor/highlight.js/styles/github-dark.css" media="(prefers-color-scheme: dark)" />
|
|
533
|
+
<link rel="stylesheet" href="/static/vendor/katex/katex.min.css" />
|
|
534
|
+
<link rel="stylesheet" href="/static/app.css" />
|
|
535
|
+
</head>
|
|
536
|
+
<body>
|
|
537
|
+
<header class="topbar">
|
|
538
|
+
<div class="topbar-row">
|
|
539
|
+
<a class="brand" href="/tree/">${escapeHtml(repoName)}</a>
|
|
540
|
+
<div class="meta">
|
|
541
|
+
<span class="pill">${branch}</span>
|
|
542
|
+
${commit ? `<span class="pill mono">${commit}</span>` : ""}
|
|
543
|
+
<span id="conn-status" class="conn-status" title="Live reload: connecting..."></span>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
</header>
|
|
547
|
+
<main class="container">
|
|
548
|
+
${body}
|
|
549
|
+
</main>
|
|
550
|
+
<script defer src="/static/vendor/katex/katex.min.js"></script>
|
|
551
|
+
<script defer src="/static/vendor/katex/contrib/auto-render.min.js"></script>
|
|
552
|
+
<script type="module" src="/static/app.js"></script>
|
|
553
|
+
<script type="module" src="/static/review.js"></script>
|
|
554
|
+
</body>
|
|
555
|
+
</html>`;
|
|
556
|
+
}
|
|
557
|
+
|
|
380
558
|
export function renderErrorPage({ title, message }) {
|
|
381
559
|
return `<!doctype html>
|
|
382
560
|
<html lang="en">
|