feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
DevPlace CI / test (push) Failing after 21m53s

Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
This commit is contained in:
2026-06-15 12:10:14 +00:00
parent 3c7527988e
commit e1874f9b6a
71 changed files with 2198 additions and 145 deletions
+2
View File
@@ -25,6 +25,7 @@ ZIPS_DIR = DATA_DIR / "zips"
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
SEO_REPORTS_DIR = DATA_DIR / "seo_reports"
PLANNING_REPORTS_DIR = DATA_DIR / "planning_reports"
DBAPI_DIR = DATA_DIR / "dbapi"
DEEPSEARCH_DIR = DATA_DIR / "deepsearch"
DEEPSEARCH_CHROMA_DIR = DEEPSEARCH_DIR / "chroma"
@@ -103,6 +104,7 @@ DATA_PATHS: dict[str, Path] = {
"zip_staging": ZIP_STAGING_DIR,
"fork_staging": FORK_STAGING_DIR,
"seo_reports": SEO_REPORTS_DIR,
"planning_reports": PLANNING_REPORTS_DIR,
"dbapi": DBAPI_DIR,
"deepsearch": DEEPSEARCH_DIR,
"deepsearch_chroma": DEEPSEARCH_CHROMA_DIR,
+23
View File
@@ -308,6 +308,29 @@ def create_comment_record(
return comment_uid, comment_url
def edit_comment_record(request, user: dict, comment: dict, content: str) -> str:
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
updated_at = datetime.now(timezone.utc).isoformat()
get_table("comments").update(
{"uid": comment["uid"], "content": content, "updated_at": updated_at}, ["uid"]
)
logger.info(f"Comment {comment['uid']} edited by {user['username']}")
audit.record(
request,
"comment.edit",
user=user,
target_type="comment",
target_uid=comment["uid"],
summary=f"{user['username']} edited a comment under {target_type} {target_uid}",
links=[
audit.target("comment", comment["uid"]),
audit.parent(target_type, target_uid),
],
)
return updated_at
def delete_comment_record(request, user: dict, comment: dict) -> tuple[str, str]:
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
+27 -7
View File
@@ -1190,10 +1190,11 @@ def load_comments(target_type, target_uid, user=None):
top.append(item)
return top
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
if not post_uids or "comments" not in db.tables:
def get_recent_comments_by_target_uids(target_type, target_uids, limit=3, user=None):
if not target_uids or "comments" not in db.tables:
return {}
placeholders, params = _in_clause(post_uids)
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
params["lim"] = limit
raw = list(
db.query(
@@ -1201,7 +1202,7 @@ def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
f" SELECT *, ROW_NUMBER() OVER ("
f" PARTITION BY target_uid ORDER BY created_at DESC, id DESC"
f" ) AS rn FROM comments"
f" WHERE target_type='post' AND target_uid IN ({placeholders}) AND deleted_at IS NULL"
f" WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL"
f") WHERE rn <= :lim ORDER BY target_uid, created_at ASC",
**params,
)
@@ -1209,10 +1210,29 @@ def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
if not raw:
return {}
items = _build_comment_items(raw, user)
result = defaultdict(list)
by_target = defaultdict(list)
for c in raw:
result[c["target_uid"]].append(items[c["uid"]])
return dict(result)
by_target[c["target_uid"]].append(c)
result = {}
for target_uid, group in by_target.items():
in_group = {c["uid"] for c in group}
top = []
for c in group:
item = items[c["uid"]]
item["children"] = []
for c in group:
item = items[c["uid"]]
parent = c.get("parent_uid")
if parent and parent in in_group:
items[parent]["children"].append(item)
else:
top.append(item)
result[target_uid] = top
return result
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
return get_recent_comments_by_target_uids("post", post_uids, limit, user)
def load_comments_by_target_uids(target_type, target_uids, user=None):
+86 -1
View File
@@ -935,6 +935,39 @@ four ways to sign requests.
],
notes=["Either `target_uid` or `post_uid` is required."],
),
endpoint(
id="comments-edit",
method="POST",
path="/comments/edit/{comment_uid}",
title="Edit a comment",
summary="Edit the body of a comment you own. Returns the updated comment.",
auth="user",
encoding="form",
params=[
field(
"comment_uid",
"path",
"string",
True,
"COMMENT_UID",
"UID of the comment.",
),
field(
"content",
"form",
"textarea",
True,
"Edited body.",
"New body, 3-1000 characters.",
),
],
sample_response={
"uid": "COMMENT_UID",
"content": "Edited body.",
"url": "/posts/POST_SLUG#comment-COMMENT_UID",
"updated_at": "2026-06-15T12:00:00+00:00",
},
),
endpoint(
id="comments-delete",
method="POST",
@@ -3711,6 +3744,55 @@ four ways to sign requests.
"completed_at": "2026-06-12T09:00:03+00:00",
},
),
endpoint(
id="issues-planning-queue",
method="POST",
path="/issues/planning",
title="Generate a tickets planning report",
summary="Enqueue a grouped, ordered markdown planning report of all open tickets. Admin only.",
auth="admin",
encoding="form",
ajax=True,
params=[],
notes=["Returns a job uid and status_url. Poll the status_url until status is done to read the markdown and download it."],
sample_response={
"uid": "PLANNING_JOB_UID",
"status_url": "/issues/planning/PLANNING_JOB_UID",
},
),
endpoint(
id="issues-planning-status",
method="GET",
path="/issues/planning/{uid}",
title="Planning report job status",
summary="Poll the planning job; the result carries the rendered markdown and the download URL. Admin only.",
auth="admin",
ajax=True,
params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
sample_response={
"uid": "PLANNING_JOB_UID",
"kind": "planning",
"status": "done",
"download_url": "/issues/planning/PLANNING_JOB_UID/download",
"markdown": "# Open Tickets Planning\n\n...",
"ai_used": True,
"issue_count": 12,
"bytes_out": 4096,
"error": None,
"created_at": "2026-06-15T09:00:00+00:00",
"completed_at": "2026-06-15T09:00:05+00:00",
},
),
endpoint(
id="issues-planning-download",
method="GET",
path="/issues/planning/{uid}/download",
title="Download the planning report",
summary="Download the generated planning report as a markdown file. Admin only.",
auth="admin",
interactive=True,
params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
),
endpoint(
id="issues-detail",
method="GET",
@@ -4535,7 +4617,10 @@ _ACTION_RESPONSES = {
"/posts/POST_SLUG#comment-COMMENT_UID",
{"uid": "COMMENT_UID", "url": "/posts/POST_SLUG#comment-COMMENT_UID"},
),
"comments-delete": ("/posts/POST_SLUG", None),
"comments-delete": (
"/posts/POST_SLUG",
{"deleted_uid": "COMMENT_UID", "target_type": "post", "target_uid": "TARGET_UID"},
),
"projects-create": (
"/projects/PROJECT_SLUG",
{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "url": "/projects/PROJECT_SLUG"},
+2
View File
@@ -85,6 +85,7 @@ from devplacepy.services.devii import DeviiService
from devplacepy.services.jobs.zip_service import ZipService
from devplacepy.services.jobs.fork_service import ForkService
from devplacepy.services.jobs.issue_create_service import IssueCreateService
from devplacepy.services.jobs.planning_service import PlanningReportService
from devplacepy.services.jobs.seo.service import SeoService
from devplacepy.services.dbapi.service import DbApiJobService
from devplacepy.services.pubsub import PubSubService
@@ -196,6 +197,7 @@ async def lifespan(app: FastAPI):
service_manager.register(PubSubService())
service_manager.register(DeepsearchService())
service_manager.register(IssueCreateService())
service_manager.register(PlanningReportService())
service_manager.register(IssueTrackerService())
service_manager.register(ContainerService())
service_manager.register(XmlrpcService())
+4
View File
@@ -168,6 +168,10 @@ class CommentForm(BaseModel):
return self
class CommentEditForm(BaseModel):
content: str = Field(min_length=3, max_length=1000)
class ProjectForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(min_length=1, max_length=5000)
+6
View File
@@ -625,6 +625,12 @@ def list_files(project_uid: str) -> list:
return [node_to_dict(row) for row in rows]
def count_files(project_uid: str) -> int:
if "project_files" not in db.tables:
return 0
return _table().count(project_uid=project_uid, type="file", deleted_at=None)
def read_file(project_uid: str, raw_path: str) -> dict:
path = normalize_path(raw_path)
node = get_node(project_uid, path)
+2
View File
@@ -6,6 +6,7 @@ from devplacepy.routers.admin import (
auditlog,
bots,
containers,
issues,
media,
news,
notifications,
@@ -24,6 +25,7 @@ router.include_router(trash.router)
router.include_router(settings.router)
router.include_router(notifications.router)
router.include_router(news.router)
router.include_router(issues.router)
router.include_router(auditlog.router)
router.include_router(bots.router)
router.include_router(services.router, prefix="/services")
+38
View File
@@ -0,0 +1,38 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.seo import base_seo_context
from devplacepy.services.gitea.config import gitea_config
from devplacepy.templating import templates
from devplacepy.utils import require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/issues/planning", response_class=HTMLResponse)
async def admin_issues_planning(request: Request):
admin = require_admin(request)
seo_ctx = base_seo_context(
request,
title="Ticket Planning - Admin",
description="Generate a grouped, ordered planning report of all open tickets.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Issues", "url": "/issues"},
],
)
context = {
**seo_ctx,
"request": request,
"user": admin,
"admin_section": "issues",
"configured": gitea_config().is_configured,
}
return templates.TemplateResponse(request, "admin_issues_planning.html", context)
+61 -3
View File
@@ -4,11 +4,18 @@ import logging
from typing import Annotated
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse
from fastapi.responses import JSONResponse
from devplacepy.database import get_table, resolve_object_url
from devplacepy.content import is_owner, create_comment_record, delete_comment_record
from devplacepy.content import (
is_owner,
create_comment_record,
delete_comment_record,
edit_comment_record,
)
from devplacepy.utils import require_user, is_admin
from devplacepy.models import CommentForm
from devplacepy.models import CommentForm, CommentEditForm
from devplacepy.responses import action_result, wants_json, json_error
from devplacepy.schemas import CommentEditOut
from devplacepy.services.audit import record as audit
logger = logging.getLogger(__name__)
@@ -33,6 +40,49 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
)
@router.post("/edit/{comment_uid}")
async def edit_comment(
request: Request, comment_uid: str, data: Annotated[CommentEditForm, Form()]
):
user = require_user(request)
comments = get_table("comments")
comment = comments.find_one(uid=comment_uid, deleted_at=None)
if not comment or not is_owner(comment, user):
target_type = comment.get("target_type", "post") if comment else "post"
target_uid = (
(comment.get("target_uid") or comment.get("post_uid", "")) if comment else ""
)
audit.record(
request,
"comment.edit",
user=user,
result="denied",
target_type="comment",
target_uid=comment_uid,
summary=f"{user['username']} denied editing comment {comment_uid}",
)
if wants_json(request):
return json_error(403, "Not allowed")
return RedirectResponse(
url=resolve_object_url(target_type, target_uid), status_code=302
)
content = data.content.strip()
updated_at = edit_comment_record(request, user, comment, content)
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
comment_url = f"{resolve_object_url(target_type, target_uid)}#comment-{comment_uid}"
if wants_json(request):
return JSONResponse(
CommentEditOut(
uid=comment_uid,
content=content,
url=comment_url,
updated_at=updated_at,
).model_dump(mode="json")
)
return RedirectResponse(url=comment_url, status_code=302)
@router.post("/delete/{comment_uid}")
async def delete_comment(request: Request, comment_uid: str):
user = require_user(request)
@@ -53,4 +103,12 @@ async def delete_comment(request: Request, comment_uid: str):
return RedirectResponse(url="/feed", status_code=302)
target_type, target_uid = delete_comment_record(request, user, comment)
redirect_url = resolve_object_url(target_type, target_uid)
return action_result(request, redirect_url)
return action_result(
request,
redirect_url,
data={
"deleted_uid": comment_uid,
"target_type": target_type,
"target_uid": target_uid,
},
)
+8 -1
View File
@@ -9,6 +9,7 @@ from devplacepy.database import (
get_table,
get_users_by_uids,
get_gist_languages,
get_recent_comments_by_target_uids,
paginate,
text_search_clause,
)
@@ -86,7 +87,13 @@ def get_gists_list(user_uid=None, language=None, search="", before=None, viewer=
return [], next_cursor, total
users_map = get_users_by_uids([g["user_uid"] for g in gists])
return enrich_items(gists, "gist", users_map, user=viewer), next_cursor, total
enriched = enrich_items(gists, "gist", users_map, user=viewer)
recent_comments = get_recent_comments_by_target_uids(
"gist", [g["uid"] for g in gists], 3, viewer
)
for item in enriched:
item["recent_comments"] = recent_comments.get(item["gist"]["uid"], [])
return enriched, next_cursor, total
@router.get("", response_class=HTMLResponse)
+2 -1
View File
@@ -1,8 +1,9 @@
# retoor <retoor@molodetz.nl>
from devplacepy.routers.issues import comment, create, status
from devplacepy.routers.issues import comment, create, planning, status
from devplacepy.routers.issues.index import router
router.include_router(planning.router)
router.include_router(create.router)
router.include_router(comment.router)
router.include_router(status.router)
+2 -1
View File
@@ -45,6 +45,7 @@ async def issues_page(request: Request, state: str = STATE_OPEN, page: int = 1):
"pagination": None,
"configured": True,
"error_message": None,
"viewer_is_admin": is_admin(user),
}
config = gitea_config()
@@ -73,7 +74,7 @@ async def issues_page(request: Request, state: str = STATE_OPEN, page: int = 1):
return respond(request, "issues.html", base_ctx, model=IssuesOut)
@router.get("/{number}", response_class=HTMLResponse)
@router.get("/{number:int}", response_class=HTMLResponse)
async def issue_detail(request: Request, number: int):
user = get_current_user(request)
if not gitea_config().is_configured:
+96
View File
@@ -0,0 +1,96 @@
# retoor <retoor@molodetz.nl>
import logging
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse
from devplacepy.config import PLANNING_REPORTS_DIR
from devplacepy.responses import json_error
from devplacepy.schemas import PlanningJobOut
from devplacepy.services.audit import record as audit
from devplacepy.services.gitea.config import gitea_config
from devplacepy.services.jobs import queue
from devplacepy.utils import not_found, require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
PLANNING_KIND = "planning"
RETENTION_EXTEND_SECONDS = 7 * 24 * 60 * 60
PREFERRED_NAME = "open-tickets-plan"
def _status_payload(job: dict) -> dict:
result = job.get("result", {})
done = job.get("status") == queue.DONE
return {
"uid": job.get("uid", ""),
"kind": job.get("kind", ""),
"status": job.get("status", ""),
"preferred_name": job.get("preferred_name"),
"download_url": result.get("download_url") if done else None,
"markdown": result.get("markdown") if done else None,
"ai_used": bool(result.get("ai_used")) if done else False,
"error": job.get("error") or None,
"issue_count": int(result.get("item_count") or 0),
"bytes_out": int(job.get("bytes_out") or result.get("bytes_out") or 0),
"created_at": job.get("created_at"),
"completed_at": job.get("completed_at") or None,
}
@router.post("/planning")
async def planning_generate(request: Request):
admin = require_admin(request)
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
uid = queue.enqueue("planning", {}, "user", admin["uid"], PREFERRED_NAME)
audit.record(
request,
"issue.planning.request",
user=admin,
target_type="issue",
summary="requested an open-tickets planning report",
links=[audit.job(uid)],
)
return JSONResponse({"uid": uid, "status_url": f"/issues/planning/{uid}"})
@router.get("/planning/{uid}")
async def planning_status(request: Request, uid: str):
require_admin(request)
job = queue.get_job(uid)
if not job or job.get("kind") != PLANNING_KIND:
raise not_found("Planning job not found")
return JSONResponse(
PlanningJobOut.model_validate(_status_payload(job)).model_dump(mode="json")
)
@router.get("/planning/{uid}/download")
async def planning_download(request: Request, uid: str):
require_admin(request)
job = queue.get_job(uid)
if (
not job
or job.get("kind") != PLANNING_KIND
or job.get("status") != queue.DONE
):
raise not_found("Planning report not available")
result = job.get("result", {})
local_path = result.get("local_path", "")
resolved = Path(local_path).resolve()
if (
not local_path
or not resolved.is_relative_to(PLANNING_REPORTS_DIR.resolve())
or not resolved.is_file()
):
raise not_found("Planning report not available")
queue.touch_job(uid, RETENTION_EXTEND_SECONDS)
return FileResponse(
resolved,
filename=result.get("final_name", "open-tickets-plan.md"),
media_type="text/markdown",
)
+5
View File
@@ -10,6 +10,7 @@ from devplacepy.database import (
load_comments,
resolve_by_slug,
get_news_images_by_uids,
get_recent_comments_by_target_uids,
get_user_bookmarks,
paginate,
)
@@ -51,6 +52,9 @@ async def news_page(request: Request, before: str = None):
article_uids = [a["uid"] for a in articles]
images_by_news = get_news_images_by_uids(article_uids)
recent_comments = get_recent_comments_by_target_uids(
"news", article_uids, 3, user
)
enriched = []
for a in articles:
@@ -60,6 +64,7 @@ async def news_page(request: Request, before: str = None):
"time_ago": time_ago(a["synced_at"]),
"image_url": images_by_news.get(a["uid"]),
"grade": a.get("grade", 0),
"recent_comments": recent_comments.get(a["uid"], []),
}
)
+7
View File
@@ -11,6 +11,7 @@ from devplacepy.database import (
get_users_by_uids,
get_site_stats,
get_user_votes,
get_recent_comments_by_target_uids,
paginate,
text_search_clause,
resolve_by_slug,
@@ -18,6 +19,7 @@ from devplacepy.database import (
count_forks,
get_top_authors,
)
from devplacepy.project_files import count_files
from devplacepy.services.jobs import queue
from devplacepy.content import (
load_detail,
@@ -95,10 +97,14 @@ def get_projects_list(
user_votes = (
get_user_votes(viewer["uid"], [p["uid"] for p in page]) if viewer else {}
)
recent_comments = get_recent_comments_by_target_uids(
"project", [p["uid"] for p in page], 3, viewer
)
for p in page:
author = users_map.get(p["user_uid"])
p["author_name"] = author["username"] if author else "Unknown"
p["my_vote"] = user_votes.get(p["uid"], 0)
p["recent_comments"] = recent_comments.get(p["uid"], [])
return page, next_cursor, total
@@ -205,6 +211,7 @@ async def project_detail(request: Request, project_slug: str):
"read_only": bool(project.get("read_only")),
"forked_from": forked_from,
"fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]),
},
),
model=ProjectDetailOut,
+26
View File
@@ -85,6 +85,21 @@ class ZipJobOut(_Out):
completed_at: Optional[str] = None
class PlanningJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
preferred_name: Optional[str] = None
download_url: Optional[str] = None
markdown: Optional[str] = None
ai_used: bool = False
error: Optional[str] = None
issue_count: int = 0
bytes_out: int = 0
created_at: Optional[str] = None
completed_at: Optional[str] = None
class ForkJobOut(_Out):
uid: str = ""
kind: str = ""
@@ -333,6 +348,13 @@ class CommentOut(_Out):
created_at: Optional[str] = None
class CommentEditOut(_Out):
uid: str = ""
content: str = ""
url: str = ""
updated_at: Optional[str] = None
class CommentItemOut(_Out):
comment: CommentOut
author: Optional[UserOut] = None
@@ -386,11 +408,13 @@ class GistItemOut(_Out):
time_ago: Optional[str] = None
my_vote: int = 0
comment_count: int = 0
recent_comments: list[CommentItemOut] = []
class ProjectListItemOut(ProjectOut):
author_name: Optional[str] = None
my_vote: int = 0
recent_comments: list[CommentItemOut] = []
class NewsListItemOut(_Out):
@@ -398,6 +422,7 @@ class NewsListItemOut(_Out):
time_ago: Optional[str] = None
image_url: Optional[str] = None
grade: Optional[int] = None
recent_comments: list[CommentItemOut] = []
class ConversationOut(_Out):
@@ -564,6 +589,7 @@ class ProjectDetailOut(_Out):
read_only: bool = False
forked_from: Optional[dict] = None
fork_count: int = 0
file_count: int = 0
class GistsOut(_Out):
+70 -8
View File
@@ -6,7 +6,8 @@ import hashlib
import logging
import math
import re
from dataclasses import dataclass
import time
from dataclasses import dataclass, field
from devplacepy import stealth
from devplacepy.config import INTERNAL_EMBED_MODEL, INTERNAL_EMBED_URL
@@ -22,6 +23,23 @@ TOKEN_PATTERN = re.compile(r"[a-z0-9]+")
class EmbedResult:
vectors: list[list[float]]
backend: str
latency_ms: int = 0
cache_hits: int = 0
@dataclass
class EmbeddingCache:
store: dict[str, list[float]] = field(default_factory=dict)
def key(self, text: str) -> str:
return hashlib.sha1((text or "").encode("utf-8")).hexdigest()
def get(self, text: str) -> list[float] | None:
return self.store.get(self.key(text))
def put(self, text: str, vector: list[float]) -> None:
if vector:
self.store[self.key(text)] = vector
def _local_vector(text: str) -> list[float]:
@@ -41,19 +59,51 @@ def _local_vector(text: str) -> list[float]:
def local_embed(texts: list[str]) -> EmbedResult:
return EmbedResult(vectors=[_local_vector(text) for text in texts], backend="local")
start = time.monotonic()
vectors = [_local_vector(text) for text in texts]
latency_ms = int((time.monotonic() - start) * 1000)
return EmbedResult(vectors=vectors, backend="local", latency_ms=latency_ms)
async def embed_texts(
texts: list[str], api_key: str, *, gateway_url: str = INTERNAL_EMBED_URL
texts: list[str],
api_key: str,
*,
gateway_url: str = INTERNAL_EMBED_URL,
cache: EmbeddingCache | None = None,
) -> EmbedResult:
if not texts:
return EmbedResult(vectors=[], backend="empty")
cache_hits = 0
pending_index: list[int] = []
pending_text: list[str] = []
resolved: list[list[float] | None] = [None] * len(texts)
if cache is not None:
for index, text in enumerate(texts):
cached_vector = cache.get(text)
if cached_vector is not None:
resolved[index] = cached_vector
cache_hits += 1
else:
pending_index.append(index)
pending_text.append(text)
else:
pending_index = list(range(len(texts)))
pending_text = list(texts)
if not pending_text:
return EmbedResult(
vectors=[vector or [] for vector in resolved],
backend="cache",
latency_ms=0,
cache_hits=cache_hits,
)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {"model": INTERNAL_EMBED_MODEL, "input": texts}
payload = {"model": INTERNAL_EMBED_MODEL, "input": pending_text}
start = time.monotonic()
backend = "gateway"
try:
async with stealth.stealth_async_client(timeout=EMBED_TIMEOUT_SECONDS) as client:
response = await client.post(gateway_url, json=payload, headers=headers)
@@ -61,10 +111,22 @@ async def embed_texts(
raise RuntimeError(f"embed gateway returned {response.status_code}")
data = response.json()
rows = data.get("data") or []
vectors = [row.get("embedding") or [] for row in rows]
if len(vectors) != len(texts) or any(not vector for vector in vectors):
fresh = [row.get("embedding") or [] for row in rows]
if len(fresh) != len(pending_text) or any(not vector for vector in fresh):
raise RuntimeError("embed gateway returned an incomplete response")
return EmbedResult(vectors=vectors, backend="gateway")
except Exception as exc:
logger.warning("deepsearch embedding gateway failed, using local: %s", exc)
return local_embed(texts)
fresh = [_local_vector(text) for text in pending_text]
backend = "local"
latency_ms = int((time.monotonic() - start) * 1000)
for offset, index in enumerate(pending_index):
vector = fresh[offset]
resolved[index] = vector
if cache is not None:
cache.put(pending_text[offset], vector)
return EmbedResult(
vectors=[vector or [] for vector in resolved],
backend=backend,
latency_ms=latency_ms,
cache_hits=cache_hits,
)
+29 -9
View File
@@ -15,8 +15,7 @@ logger = logging.getLogger(__name__)
TOKEN_PATTERN = re.compile(r"[a-z0-9]+")
BM25_K1 = 1.5
BM25_B = 0.75
HYBRID_VECTOR_WEIGHT = 0.6
HYBRID_KEYWORD_WEIGHT = 0.4
RRF_K = 60.0
DEFAULT_TOP_K = 8
CANDIDATE_MULTIPLIER = 4
@@ -182,18 +181,39 @@ class VectorStore:
)
if not candidates:
return []
vector_rank = sorted(
candidates, key=lambda chunk: chunk.score, reverse=True
)
keyword = self.keyword_scores(query, candidates)
vec_max = max((chunk.score for chunk in candidates), default=0.0) or 1.0
kw_max = max(keyword.values(), default=0.0) or 1.0
keyword_rank = sorted(
candidates, key=lambda chunk: keyword.get(chunk.uid, 0.0), reverse=True
)
fused: dict[str, float] = {}
for rank, chunk in enumerate(vector_rank, start=1):
fused[chunk.uid] = fused.get(chunk.uid, 0.0) + 1.0 / (RRF_K + rank)
for rank, chunk in enumerate(keyword_rank, start=1):
fused[chunk.uid] = fused.get(chunk.uid, 0.0) + 1.0 / (RRF_K + rank)
for chunk in candidates:
vec_norm = max(0.0, chunk.score) / vec_max
kw_norm = keyword.get(chunk.uid, 0.0) / kw_max
chunk.score = (
HYBRID_VECTOR_WEIGHT * vec_norm + HYBRID_KEYWORD_WEIGHT * kw_norm
)
chunk.score = fused.get(chunk.uid, 0.0)
candidates.sort(key=lambda chunk: chunk.score, reverse=True)
return candidates[:top_k]
def coverage_analytics(self) -> dict:
chunks = self.all_chunks()
if not chunks:
return {"chunks": 0, "domains": 0, "sources": 0, "avg_chunk_chars": 0}
domains = {chunk.metadata.get("url", "") for chunk in chunks}
domains.discard("")
sources = {chunk.source for chunk in chunks}
sources.discard("")
avg_chars = int(sum(len(chunk.text) for chunk in chunks) / len(chunks))
return {
"chunks": len(chunks),
"domains": len(domains),
"sources": len(sources),
"avg_chunk_chars": avg_chars,
}
def drop(self) -> None:
try:
import chromadb
@@ -197,6 +197,16 @@ ACTIONS: tuple[Action, ...] = (
body("attachment_uids", ATTACHMENTS),
),
),
Action(
name="edit_comment",
method="POST",
path="/comments/edit/{comment_uid}",
summary="Edit the body of one of your own comments",
params=(
path("comment_uid", "Uid of the comment."),
body("content", "New comment body, 3-1000 characters.", required=True),
),
),
Action(
name="delete_comment",
method="POST",
@@ -910,6 +920,20 @@ ACTIONS: tuple[Action, ...] = (
body("description", "Issue description.", required=True),
),
),
Action(
name="planning_report_generate",
method="POST",
path="/issues/planning",
summary="Queue an admin planning report of all open tickets.",
description=(
"Queues a background job that builds a grouped, ordered markdown planning report "
"of every open ticket and returns {uid, status_url}. Poll the status_url until "
"status is 'done', then show the markdown and the download_url. Admin only."
),
params=(),
requires_auth=True,
requires_admin=True,
),
Action(
name="issue_job_status",
method="GET",
+159
View File
@@ -0,0 +1,159 @@
# retoor <retoor@molodetz.nl>
import logging
import httpx
from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL
from devplacepy.services.gitea.config import HTTP_TIMEOUT_SECONDS, GiteaConfig
logger = logging.getLogger(__name__)
MAX_TOKENS = 3000
MAX_ISSUES = 50
BODY_EXCERPT_MAX = 600
PLAN_MAX = 80000
UNGROUPED_LABEL = "General"
SYSTEM_PROMPT = (
"You are a senior engineering lead for DevPlace, a social network for software "
"developers. You are given the full list of OPEN issue tickets from a Gitea tracker. "
"Produce a single, clear delivery plan in GitHub-flavored markdown that GROUPS the "
"tickets into logical workstreams and ORDERS them so dependencies and high-impact work "
"come first. Never invent tickets, numbers, or facts that are not in the input.\n\n"
"Return ONLY markdown, using EXACTLY this structure:\n"
"# Open Tickets Planning\n"
"A short paragraph summarising the overall plan and how many tickets it covers.\n\n"
"Then one level-2 heading per group (a workstream or theme). Under each group, an "
"ordered (numbered) list of its tickets. Each list item MUST start with the ticket "
"number as '#N', then the title, then a single sentence explaining the chosen order "
"or the work to do, for example:\n"
"1. #12 Fix the login redirect - blocks every authenticated flow, do first.\n\n"
"End with a level-2 heading '## Suggested Order' giving a flat numbered list of every "
"ticket number in the recommended execution order."
)
def _excerpt(text: str) -> str:
text = (text or "").strip().replace("\r\n", "\n")
if len(text) > BODY_EXCERPT_MAX:
return text[:BODY_EXCERPT_MAX].rstrip() + "..."
return text
def _labels(issue: dict) -> list[str]:
labels = issue.get("labels") or []
names: list[str] = []
for label in labels:
if isinstance(label, dict):
name = str(label.get("name", "")).strip()
else:
name = str(label).strip()
if name:
names.append(name)
return names
def _group_key(issue: dict) -> str:
labels = _labels(issue)
return labels[0] if labels else UNGROUPED_LABEL
def _fallback(issues: list[dict]) -> str:
groups: dict[str, list[dict]] = {}
for issue in issues:
groups.setdefault(_group_key(issue), []).append(issue)
ordered_group_names = sorted(
groups,
key=lambda name: (name == UNGROUPED_LABEL, name.lower()),
)
lines: list[str] = ["# Open Tickets Planning", ""]
lines.append(
f"Deterministic plan covering {len(issues)} open ticket"
f"{'' if len(issues) == 1 else 's'}, grouped by primary label and ordered by "
"ticket number within each group."
)
lines.append("")
flat_order: list[int] = []
for name in ordered_group_names:
members = sorted(groups[name], key=lambda i: int(i.get("number", 0)))
lines.append(f"## {name}")
for position, issue in enumerate(members, start=1):
number = int(issue.get("number", 0))
title = str(issue.get("title", "")).strip() or "Untitled"
excerpt = _excerpt(issue.get("body", ""))
rationale = excerpt or "No description provided."
lines.append(f"{position}. #{number} {title} - {rationale}")
flat_order.append(number)
lines.append("")
lines.append("## Suggested Order")
for position, number in enumerate(flat_order, start=1):
lines.append(f"{position}. #{number}")
lines.append("")
return "\n".join(lines)[:PLAN_MAX]
def _user_message(issues: list[dict]) -> str:
parts: list[str] = ["Open tickets to plan:", ""]
for issue in issues:
number = int(issue.get("number", 0))
title = str(issue.get("title", "")).strip() or "Untitled"
labels = ", ".join(_labels(issue)) or "none"
excerpt = _excerpt(issue.get("body", ""))
parts.append(f"#{number} {title}")
parts.append(f"labels: {labels}")
parts.append(f"description: {excerpt or 'none'}")
parts.append("")
return "\n".join(parts)
async def generate_plan(
issues: list[dict], config: GiteaConfig
) -> tuple[str, bool]:
issues = list(issues)[:MAX_ISSUES]
if not issues:
return "# Open Tickets Planning\n\nThere are no open tickets to plan.\n", False
if not config.ai_enhance:
return _fallback(issues), False
payload = {
"model": config.ai_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": _user_message(issues)},
],
"max_tokens": MAX_TOKENS,
"temperature": 0.2,
}
headers = {"Content-Type": "application/json"}
if config.ai_key:
headers["Authorization"] = f"Bearer {config.ai_key}"
try:
async with stealth.stealth_async_client(timeout=HTTP_TIMEOUT_SECONDS) as client:
response = await client.post(
INTERNAL_GATEWAY_URL, json=payload, headers=headers
)
response.raise_for_status()
content = (
response.json()
.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
logger.warning("Planning generation failed, using fallback: %s", exc)
return _fallback(issues), False
markdown = (content or "").strip()
if not markdown:
logger.warning("Planning generation returned empty content, using fallback")
return _fallback(issues), False
logger.info("Generated AI planning for %d open tickets", len(issues))
return markdown[:PLAN_MAX], True
@@ -2,9 +2,11 @@
from __future__ import annotations
import asyncio
import hashlib
import logging
import re
import time
from dataclasses import dataclass, field
from typing import Awaitable, Callable
@@ -14,6 +16,7 @@ from devplacepy import stealth
from devplacepy.net_guard import BlockedAddressError, guard_public_url, guarded_async_client
from .pdf import MAX_PDF_BYTES, extract_pdf_text, is_pdf
from .phases import PHASE_CRAWLING
logger = logging.getLogger(__name__)
@@ -22,6 +25,7 @@ RSEARCH_TIMEOUT_SECONDS = 45.0
FETCH_TIMEOUT_SECONDS = 20.0
MAX_FETCH_BYTES = 2_500_000
RESULTS_PER_QUERY = 8
CRAWL_CONCURRENCY = 4
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36 DevPlaceDeepSearchBot/1.0"
+33 -2
View File
@@ -5,10 +5,13 @@ from __future__ import annotations
import json
import logging
import re
from typing import Callable
from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
from .phases import PHASE_PLANNING
logger = logging.getLogger(__name__)
ENHANCE_TIMEOUT_SECONDS = 90.0
@@ -52,7 +55,20 @@ def _parse(text: str) -> list[str]:
return cleaned[:MAX_SUBQUERIES]
async def plan_queries(query: str, api_key: str) -> list[str]:
def _noop(frame: dict) -> None:
return None
async def plan_queries(
query: str, api_key: str, emit: Callable[[dict], None] = _noop
) -> list[str]:
emit(
{
"type": "substep",
"phase": PHASE_PLANNING,
"message": "Drafting diverse search angles",
}
)
payload = {
"model": INTERNAL_MODEL,
"messages": [
@@ -79,7 +95,22 @@ async def plan_queries(query: str, api_key: str) -> list[str]:
)
parsed = _parse(content)
if parsed:
emit(
{
"type": "substep",
"phase": PHASE_PLANNING,
"message": f"Planned {len(parsed)} search angles",
}
)
return parsed
except Exception as exc:
logger.warning("deepsearch query planner failed, using fallback: %s", exc)
return _fallback(query)
fallback = _fallback(query)
emit(
{
"type": "substep",
"phase": PHASE_PLANNING,
"message": f"Planner unavailable, using {len(fallback)} heuristic angles",
}
)
return fallback
@@ -0,0 +1,38 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
PHASE_PLANNING = "planning"
PHASE_SEARCHING = "searching"
PHASE_CRAWLING = "crawling"
PHASE_INDEXING = "indexing"
PHASE_ANALYSIS = "analysis"
PHASE_SYNTHESIS = "synthesis"
PHASE_ORDER: list[str] = [
PHASE_PLANNING,
PHASE_SEARCHING,
PHASE_CRAWLING,
PHASE_INDEXING,
PHASE_ANALYSIS,
PHASE_SYNTHESIS,
]
PHASE_LABELS: dict[str, str] = {
PHASE_PLANNING: "Planning",
PHASE_SEARCHING: "Searching",
PHASE_CRAWLING: "Crawling",
PHASE_INDEXING: "Indexing",
PHASE_ANALYSIS: "Analysis",
PHASE_SYNTHESIS: "Synthesis",
}
TOTAL_PHASES = len(PHASE_ORDER)
HEARTBEAT_SECONDS = 2.0
def phase_index(phase: str) -> int:
try:
return PHASE_ORDER.index(phase)
except ValueError:
return 0
@@ -0,0 +1,119 @@
# retoor <retoor@molodetz.nl>
import logging
import zlib
from pathlib import Path
from devplacepy.attachments import _directory_for
from devplacepy.config import PLANNING_REPORTS_DIR
from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.client import STATE_OPEN, GiteaError
from devplacepy.services.gitea.config import gitea_config
from devplacepy.services.gitea.planning import MAX_ISSUES, generate_plan
from devplacepy.services.jobs.base import JobService
from devplacepy.utils import slugify
logger = logging.getLogger(__name__)
PAGE_LIMIT = 50
class PlanningReportService(JobService):
kind = "planning"
title = "Ticket planning"
description = (
"Builds a grouped, ordered markdown planning report of all open Gitea tickets off "
"the request path using the internal AI service (with a deterministic fallback), "
"writes the markdown artifact, and prunes it once unused for the retention window."
)
def __init__(self) -> None:
super().__init__(name="planning", interval_seconds=2)
async def process(self, job: dict) -> dict:
from devplacepy.services.audit import record as audit
uid: str = job["uid"]
owner_kind: str = job.get("owner_kind") or "system"
owner_id: str = job.get("owner_id") or ""
try:
config = gitea_config()
if not config.is_configured:
raise GiteaError("Gitea integration is not configured", status=503)
issues = await self._collect_open(config)
markdown, ai_used = await generate_plan(issues, config)
issue_count = len(issues)
final_name = self._final_name(job.get("preferred_name", ""), markdown)
target_dir = PLANNING_REPORTS_DIR / _directory_for(uid)
target_dir.mkdir(parents=True, exist_ok=True)
final_path = target_dir / final_name
final_path.write_text(markdown, encoding="utf-8")
bytes_out = len(markdown.encode("utf-8"))
except Exception:
audit.record_system(
"issue.planning.generate",
actor_kind="user" if owner_kind == "user" else owner_kind,
actor_uid=owner_id if owner_kind == "user" else None,
result="failure",
target_type="issue",
metadata={"job": uid},
summary="planning report generation failed",
links=[audit.job(uid)],
)
raise
audit.record_system(
"issue.planning.generate",
actor_kind="user" if owner_kind == "user" else owner_kind,
actor_uid=owner_id if owner_kind == "user" else None,
target_type="issue",
metadata={
"issue_count": issue_count,
"ai_used": ai_used,
"bytes_out": bytes_out,
},
summary=f"planning report for {issue_count} open tickets generated",
links=[audit.job(uid)],
)
self.log(f"Planning job {uid} covered {issue_count} open tickets")
return {
"download_url": f"/issues/planning/{uid}/download",
"local_path": str(final_path),
"final_name": final_name,
"markdown": markdown,
"ai_used": ai_used,
"item_count": issue_count,
"bytes_out": bytes_out,
}
async def _collect_open(self, config) -> list[dict]:
client = runtime.get_client()
collected: list[dict] = []
page = 1
while len(collected) < MAX_ISSUES:
issues, total = await client.list_issues(
state=STATE_OPEN, page=page, limit=PAGE_LIMIT
)
if not issues:
break
collected.extend(issues)
if len(issues) < PAGE_LIMIT or len(collected) >= total:
break
page += 1
return collected[:MAX_ISSUES]
def _final_name(self, preferred_name: str, markdown: str) -> str:
name = preferred_name or "open-tickets-plan"
if name.lower().endswith(".md"):
name = name[:-3]
slug = slugify(name) or "open-tickets-plan"
crc32 = zlib.crc32(markdown.encode("utf-8")) & 0xFFFFFFFF
return f"{crc32:08x}.{slug}.md"
def cleanup(self, job: dict) -> None:
result = job.get("result", {})
local_path = result.get("local_path")
if local_path:
Path(local_path).unlink(missing_ok=True)
+1 -1
View File
@@ -900,7 +900,7 @@ body:has(.page-messages) {
}
.page:has(.page-messages) {
padding-top: 0;
padding-top: var(--nav-height);
padding-bottom: 0;
flex: 1 1 auto;
min-height: 0;
+15
View File
@@ -143,6 +143,14 @@ devii-terminal.devii-state-fullscreen .devii-window {
resize: none;
}
/* On-screen keyboard inset: devii-terminal.js sets inline top/height from
visualViewport so the window ends exactly at the keyboard top and the
input row stays reachable. Drop the fixed bottom/height so the inline wins. */
devii-terminal.devii-keyboard-visible.devii-state-fullscreen .devii-window {
bottom: auto;
transition: none;
}
/* Title bar */
devii-terminal .devii-titlebar {
display: flex;
@@ -493,6 +501,13 @@ devii-terminal.devii-mobile .devii-winctl button[data-win="fullscreen"] {
height: 30px;
font-size: 16px;
}
devii-terminal .devii-inputrow {
min-height: 44px;
}
devii-terminal .devii-input {
min-height: 44px;
font-size: 16px;
}
}
/* Devii client-side tutorial overlays (attached to document.body) */
+4
View File
@@ -496,6 +496,10 @@ textarea.param-input {
.code-copy-btn {
opacity: 1;
}
.content-copy-btn {
opacity: 1;
}
}
/* ---- Unified code blocks: highlight + line numbers + copy (widget tabs, response, prose) ---- */
@@ -95,6 +95,15 @@
resize: none;
}
/* On-screen keyboard inset: visualViewport sets inline top/height from
FloatingWindow.js so the window shrinks above the keyboard instead of
being pushed off-screen. Drop the fixed bottom/height so the inline wins. */
.fw-host.fw-state-fullscreen.fw-keyboard-visible .fw-window,
.fw-host.fw-state-maximized.fw-keyboard-visible .fw-window {
bottom: auto;
transition: none;
}
.fw-host .fw-titlebar {
display: flex;
align-items: center;
+6
View File
@@ -14,6 +14,12 @@
font-size: 1.5rem;
font-weight: 700;
}
.issues-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.issues-filters {
display: flex;
gap: 0.5rem;
+62 -9
View File
@@ -399,20 +399,20 @@
}
}
.landing-bots-intro {
.landing-help-intro {
max-width: 760px;
margin: 0 0 1.5rem;
color: var(--text-secondary);
line-height: 1.6;
}
.landing-bots-grid {
.landing-help-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.landing-bot-card {
.landing-help-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
@@ -422,34 +422,87 @@
gap: 0.5rem;
}
.landing-bot-card:hover {
.landing-help-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.landing-bot-card-lead {
.landing-help-card-lead {
border-color: var(--accent);
}
.landing-bot-icon {
.landing-help-icon {
font-size: 1.75rem;
}
.landing-bot-card h3 {
.landing-help-card h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.05rem;
}
.landing-bot-card p {
.landing-help-card p {
margin: 0;
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
}
.landing-help-link {
margin-top: auto;
color: var(--accent);
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
}
.landing-help-link:hover {
text-decoration: underline;
}
.landing-help-links {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: auto;
}
.landing-help-links a {
color: var(--accent);
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
}
.landing-help-links a:hover {
text-decoration: underline;
}
.landing-help-cta {
margin-top: auto;
align-self: flex-start;
background: var(--accent);
color: var(--white);
border: none;
border-radius: var(--radius-lg);
padding: 0.6rem 1.1rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s ease;
}
.landing-help-cta:hover {
filter: brightness(1.08);
}
@media (max-width: 480px) {
.landing-bots-grid {
.landing-help-grid {
grid-template-columns: 1fr;
}
.landing-help-cta {
width: 100%;
text-align: center;
}
}
+30
View File
@@ -120,6 +120,36 @@
overflow-x: auto;
}
dp-content.rendered-content {
position: relative;
}
.content-copy-btn {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 2;
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
dp-content:hover .content-copy-btn,
.content-copy-btn:focus {
opacity: 1;
}
.content-copy-btn:hover {
color: var(--white);
border-color: var(--accent);
}
.embed-youtube {
position: relative;
width: 100%;
+22
View File
@@ -5,6 +5,7 @@
min-height: 0;
display: flex;
width: 100%;
--kb-inset: 0px;
}
.messages-layout {
@@ -292,11 +293,16 @@
.messages-input-area {
padding: 0.75rem 1rem;
padding-bottom: calc(0.75rem + var(--kb-inset) + env(safe-area-inset-bottom));
padding-left: calc(1rem + env(safe-area-inset-left));
padding-right: calc(1rem + env(safe-area-inset-right));
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
min-height: 56px;
background: var(--bg-card);
}
.messages-input-area input[type="text"] {
@@ -403,13 +409,23 @@
.messages-layout {
grid-template-columns: 1fr;
min-height: 0;
height: 100%;
border-radius: 0;
border-left: none;
border-right: none;
}
.messages-list {
display: flex;
min-height: 0;
height: 100%;
}
.messages-list.hide {
display: none;
}
.messages-main {
min-height: 0;
height: 100%;
}
.messages-main.hide {
display: none;
}
@@ -418,6 +434,9 @@
align-items: center;
justify-content: center;
}
.messages-input-area input[type="text"] {
font-size: 16px;
}
}
@media (max-width: 360px) {
@@ -427,6 +446,9 @@
.messages-input-area {
padding: 0.5rem 0.75rem;
padding-bottom: calc(0.5rem + var(--kb-inset) + env(safe-area-inset-bottom));
padding-left: calc(0.75rem + env(safe-area-inset-left));
padding-right: calc(0.75rem + env(safe-area-inset-right));
}
.message-bubble {
+82
View File
@@ -0,0 +1,82 @@
/* retoor <retoor@molodetz.nl> */
.planning-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.planning-subtitle {
margin: 0.25rem 0 0;
color: var(--text-secondary);
font-size: 0.9rem;
}
.planning-status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
margin: 1.5rem 0;
color: var(--text-secondary);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.planning-spinner {
width: 1.1rem;
height: 1.1rem;
border: 2px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: planning-spin 0.8s linear infinite;
}
@keyframes planning-spin {
to {
transform: rotate(360deg);
}
}
.planning-result {
margin: 1.5rem 0;
}
.planning-actions {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.planning-hint {
color: var(--text-secondary);
font-size: 0.85rem;
}
.planning-report {
display: block;
padding: 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.planning-empty {
margin: 1.5rem 0;
}
@media (max-width: 600px) {
.planning-header {
flex-direction: column;
align-items: stretch;
}
.planning-report {
padding: 1rem;
}
}
+2
View File
@@ -27,6 +27,7 @@ import { DeviiTerminal } from "./DeviiTerminal.js";
import { ZipDownloader } from "./ZipDownloader.js";
import { ProjectForker } from "./ProjectForker.js";
import { IssueReporter } from "./IssueReporter.js";
import { PlanningGenerator } from "./PlanningGenerator.js";
import { MediaGallery } from "./MediaGallery.js";
import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
@@ -67,6 +68,7 @@ class Application {
this.zipDownloader = new ZipDownloader();
this.projectForker = new ProjectForker();
this.issueReporter = new IssueReporter();
this.planningGenerator = new PlanningGenerator();
this.mediaGallery = new MediaGallery();
this.pubsub = new PubSubClient();
}
+119
View File
@@ -1,10 +1,45 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { contentRenderer } from "./ContentRenderer.js";
export class CommentManager {
constructor() {
this.initCommentReply();
this.initCommentEdit();
this.initCommentDelete();
}
initCommentDelete() {
document.addEventListener("submit", (e) => {
const form = e.target.closest(".comment-delete-form");
if (!form) return;
e.preventDefault();
this.submitDelete(form);
});
}
async submitDelete(form) {
const wrapper = form.closest(".comment");
if (!wrapper) return;
const button = form.querySelector("button[type='submit']");
if (button) button.disabled = true;
const anchor = wrapper.previousElementSibling
&& wrapper.previousElementSibling.classList.contains("comment")
? wrapper.previousElementSibling
: wrapper.parentElement.closest(".comment, .comments-section, .post-card");
const anchorTop = anchor
? anchor.getBoundingClientRect().top + window.scrollY
: wrapper.getBoundingClientRect().top + window.scrollY;
try {
await Http.send(form.action);
wrapper.remove();
window.scrollTo({ top: Math.max(anchorTop, 0) });
} catch (err) {
if (button) button.disabled = false;
}
}
initCommentReply() {
@@ -16,6 +51,90 @@ export class CommentManager {
});
}
initCommentEdit() {
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-action='edit']");
if (!btn) return;
e.preventDefault();
this.toggleEditForm(btn);
});
}
toggleEditForm(btn) {
const body = btn.closest(".comment-body");
if (!body) return;
const text = body.querySelector(":scope > .comment-text");
if (!text) return;
const existing = body.querySelector(":scope > .comment-edit-form");
if (existing) {
existing.remove();
text.style.display = "";
return;
}
const form = document.createElement("form");
form.className = "comment-form comment-edit-form";
form.method = "POST";
form.action = btn.dataset.editUrl;
const textarea = document.createElement("textarea");
textarea.name = "content";
textarea.className = "emoji-picker-target";
textarea.value = text.dataset.raw || text.textContent;
textarea.rows = 3;
form.appendChild(textarea);
const actions = document.createElement("div");
actions.className = "comment-form-actions";
const save = document.createElement("button");
save.type = "submit";
save.className = "btn btn-primary btn-sm";
save.textContent = "Save";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "comment-action-btn";
cancel.textContent = "Cancel";
cancel.addEventListener("click", () => {
form.remove();
text.style.display = "";
});
actions.appendChild(save);
actions.appendChild(cancel);
form.appendChild(actions);
form.addEventListener("submit", (e) => {
e.preventDefault();
this.submitEdit(form, text);
});
text.style.display = "none";
text.insertAdjacentElement("afterend", form);
const enhancer = window.app && window.app.content;
if (enhancer) enhancer.initEmojiPickers();
textarea.focus();
}
async submitEdit(form, text) {
const save = form.querySelector("button[type='submit']");
if (save) save.disabled = true;
const content = form.querySelector("textarea").value;
try {
const result = await Http.send(form.action, { content });
text.dataset.raw = result.content;
text.textContent = result.content;
contentRenderer.applyTo(text);
text.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
img.dataset.lightbox = "";
});
form.remove();
text.style.display = "";
} catch (err) {
if (save) save.disabled = false;
}
}
toggleReplyForm(btn) {
const comment = btn.closest(".comment");
if (!comment) return;
+5
View File
@@ -43,8 +43,13 @@ export class ContentRenderer {
});
}
normalizeDashes(text) {
return text.replace(/\u2014/g, "-");
}
render(text) {
if (!text) return "";
text = this.normalizeDashes(text);
text = this.replaceShortcodes(text);
let html;
+24
View File
@@ -38,9 +38,33 @@ export class MessagesLayout {
this.connect();
this.bindForm();
this.bindTyping();
this.bindViewport();
this.markRead();
}
bindViewport() {
const viewport = window.visualViewport;
if (!viewport || !this.input) {
return;
}
const applyInset = () => {
const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
this.layout.style.setProperty("--kb-inset", `${Math.round(inset)}px`);
this.scrollThreadToEnd();
};
viewport.addEventListener("resize", applyInset);
viewport.addEventListener("scroll", applyInset);
this.input.addEventListener("focus", () => {
window.setTimeout(() => {
this.scrollThreadToEnd();
this.input.scrollIntoView({ block: "end" });
}, 250);
});
this.input.addEventListener("blur", () => {
this.layout.style.setProperty("--kb-inset", "0px");
});
}
connect() {
this.socket = new MessagesSocket({
onReady: () => this.onReady(),
+87
View File
@@ -0,0 +1,87 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { JobPoller } from "./JobPoller.js";
export class PlanningGenerator {
constructor() {
this.active = false;
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-planning-generate]");
if (!button) return;
event.preventDefault();
this.generate(button);
});
}
elements() {
return {
status: document.querySelector("[data-planning-status]"),
result: document.querySelector("[data-planning-result]"),
empty: document.querySelector("[data-planning-empty]"),
report: document.querySelector("[data-planning-report]"),
download: document.querySelector("[data-planning-download]"),
};
}
async generate(button) {
if (this.active) return;
this.active = true;
button.disabled = true;
button.classList.add("is-loading");
const ui = this.elements();
if (ui.empty) ui.empty.hidden = true;
if (ui.result) ui.result.hidden = true;
if (ui.status) ui.status.hidden = false;
try {
const action = button.dataset.action || "/issues/planning";
const job = await Http.sendForm(action, {});
await this.poll(job.status_url, ui);
} catch (error) {
window.app.toast.show("Could not start the planning report", { type: "error" });
if (ui.status) ui.status.hidden = true;
if (ui.empty) ui.empty.hidden = false;
} finally {
this.active = false;
button.disabled = false;
button.classList.remove("is-loading");
}
}
poll(statusUrl, ui) {
return JobPoller.run(statusUrl, {
onDone: (status) => {
if (ui.status) ui.status.hidden = true;
this.renderReport(status, ui);
if (ui.result) ui.result.hidden = false;
window.app.toast.show("Planning report ready", { type: "success" });
},
onFailed: (status) => {
if (ui.status) ui.status.hidden = true;
if (ui.empty) ui.empty.hidden = false;
window.app.toast.show(
"Planning failed: " + (status.error || "unknown error"),
{ type: "error" },
);
},
onTimeout: () => {
if (ui.status) {
ui.status.querySelector(".planning-status-text").textContent =
"Still generating, this is taking longer than expected...";
}
},
});
}
renderReport(status, ui) {
if (ui.download && status.download_url) {
ui.download.href = status.download_url;
}
if (!ui.report) return;
const fresh = document.createElement("dp-content");
fresh.className = ui.report.className;
fresh.setAttribute("data-planning-report", "");
fresh.textContent = status.markdown || "";
ui.report.replaceWith(fresh);
}
}
@@ -13,11 +13,38 @@ export class AppContent extends Component {
return;
}
this._rendered = true;
this._source = this.textContent;
this.classList.add("rendered-content");
contentRenderer.applyTo(this);
if (typeof hljs !== "undefined") {
this.querySelectorAll("pre code").forEach((block) => hljs.highlightElement(block));
}
if (!this.hasAttribute("no-copy")) {
this.addCopyButton();
}
}
addCopyButton() {
if (!this._source || !this._source.trim()) {
return;
}
if (this.querySelector(":scope > .content-copy-btn")) {
return;
}
const btn = document.createElement("button");
btn.type = "button";
btn.className = "content-copy-btn";
btn.textContent = "Copy";
btn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(this._source);
btn.textContent = "Copied";
} catch {
btn.textContent = "Failed";
}
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
});
this.appendChild(btn);
}
}
@@ -26,7 +26,13 @@ export class AppContextMenu extends Component {
if (!this.menu.contains(e.target)) this.close();
});
document.addEventListener("scroll", () => this.close(), true);
window.addEventListener("resize", () => this.close());
this._lastWidth = window.innerWidth;
window.addEventListener("resize", () => {
if (window.innerWidth !== this._lastWidth) {
this._lastWidth = window.innerWidth;
this.close();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") this.close();
});
@@ -67,12 +73,17 @@ export class AppContextMenu extends Component {
this.render(items);
this.menu.classList.add("visible");
const rect = this.menu.getBoundingClientRect();
const vv = window.visualViewport;
const safeW = vv ? vv.width : window.innerWidth;
const safeH = vv ? vv.height : window.innerHeight;
const offX = vv ? vv.offsetLeft : 0;
const offY = vv ? vv.offsetTop : 0;
let left = x;
let top = y;
if (left + rect.width > window.innerWidth) left = window.innerWidth - rect.width - 8;
if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 8;
this.menu.style.left = Math.max(8, left) + "px";
this.menu.style.top = Math.max(8, top) + "px";
if (left + rect.width > offX + safeW) left = offX + safeW - rect.width - 8;
if (top + rect.height > offY + safeH) top = offY + safeH - rect.height - 8;
this.menu.style.left = Math.max(offX + 8, left) + "px";
this.menu.style.top = Math.max(offY + 8, top) + "px";
}
close() {
@@ -239,6 +239,7 @@ export default class ContainerTerminalElement extends FloatingWindow {
return;
}
this._sendResize();
if (this.term.scrollToBottom) this.term.scrollToBottom();
}
_onShow() {
@@ -5,6 +5,7 @@ import { assetUrl } from "../assetVersion.js";
const FW_CSS_ID = "floating-window-css";
const MOBILE_QUERY = "(max-width: 640px)";
const KEYBOARD_INSET_THRESHOLD = 120;
export default class FloatingWindow extends Component {
constructor() {
@@ -14,6 +15,18 @@ export default class FloatingWindow extends Component {
this._drag = null;
this._rafResize = null;
this._mobile = window.matchMedia(MOBILE_QUERY);
this._vv = window.visualViewport || null;
}
get _safeViewport() {
return {
width: window.innerWidth,
height: this._vv ? this._vv.height : window.innerHeight,
};
}
get _keyboardVisible() {
return !!(this._vv && window.innerHeight - this._vv.height > KEYBOARD_INSET_THRESHOLD);
}
get windowKey() {
@@ -52,6 +65,11 @@ export default class FloatingWindow extends Component {
this._scheduleResize();
};
window.addEventListener("resize", this._winResize);
this._vvHandler = () => this._onVisualViewport();
if (this._vv) {
this._vv.addEventListener("resize", this._vvHandler);
this._vv.addEventListener("scroll", this._vvHandler);
}
const saved = this._loadGeometry();
if (saved) {
this.geometry = saved;
@@ -66,6 +84,10 @@ export default class FloatingWindow extends Component {
disconnectedCallback() {
window.removeEventListener("resize", this._winResize);
this._mobile.removeEventListener("change", this._viewportHandler);
if (this._vv && this._vvHandler) {
this._vv.removeEventListener("resize", this._vvHandler);
this._vv.removeEventListener("scroll", this._vvHandler);
}
if (this._ro) this._ro.disconnect();
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.unregister(this);
this._onHide();
@@ -155,7 +177,7 @@ export default class FloatingWindow extends Component {
_atSize([width, height]) {
if (!this.geometry) return false;
const w = Math.max(320, Math.min(width, window.innerWidth - 16));
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
const h = Math.max(220, Math.min(height, this._safeViewport.height - 16));
return Math.abs(this.geometry.width - w) <= 4 && Math.abs(this.geometry.height - h) <= 4;
}
@@ -204,13 +226,14 @@ export default class FloatingWindow extends Component {
}
_presetGeometry(width, height) {
const safeHeight = this._safeViewport.height;
const w = Math.max(320, Math.min(width, window.innerWidth - 16));
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
const h = Math.max(220, Math.min(height, safeHeight - 16));
this.geometry = {
width: w,
height: h,
left: Math.max(8, Math.round((window.innerWidth - w) / 2)),
top: Math.max(8, Math.round((window.innerHeight - h) / 2)),
top: Math.max(8, Math.round((safeHeight - h) / 2)),
};
this._setState("normal");
this._persistGeometry();
@@ -246,13 +269,14 @@ export default class FloatingWindow extends Component {
}
_defaultGeometry() {
const safeHeight = this._safeViewport.height;
const width = Math.min(820, Math.round(window.innerWidth * 0.94));
const height = Math.min(560, Math.round(window.innerHeight * 0.82));
const height = Math.min(560, Math.round(safeHeight * 0.82));
return {
width,
height,
left: Math.max(12, Math.round((window.innerWidth - width) / 2)),
top: Math.max(12, Math.round((window.innerHeight - height) / 2)),
top: Math.max(12, Math.round((safeHeight - height) / 2)),
};
}
@@ -268,10 +292,11 @@ export default class FloatingWindow extends Component {
_clampGeometry() {
if (!this.geometry) return;
const g = this.geometry;
const safeHeight = this._safeViewport.height;
g.width = Math.min(g.width, window.innerWidth - 16);
g.height = Math.min(g.height, window.innerHeight - 16);
g.height = Math.min(g.height, safeHeight - 16);
g.left = Math.max(8, Math.min(g.left, window.innerWidth - g.width - 8));
g.top = Math.max(8, Math.min(g.top, window.innerHeight - g.height - 8));
g.top = Math.max(8, Math.min(g.top, safeHeight - g.height - 8));
}
_clampWindow() {
@@ -287,6 +312,16 @@ export default class FloatingWindow extends Component {
this._setState("normal");
}
_onVisualViewport() {
if (!this._vv) return;
this.classList.toggle("fw-keyboard-visible", this._keyboardVisible);
if (this.win && (this.state === "fullscreen" || this.state === "maximized")) {
this.win.style.height = `${this._vv.height}px`;
this.win.style.top = `${this._vv.offsetTop}px`;
}
this._scheduleResize();
}
_startDrag(event) {
if (this.state !== "normal") return;
if (event.target.tagName === "BUTTON") return;
@@ -308,8 +343,9 @@ export default class FloatingWindow extends Component {
if (event.cancelable) event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const g = this.geometry;
const safeHeight = this._safeViewport.height;
g.left = Math.max(8, Math.min(point.clientX - this._drag.dx, window.innerWidth - g.width - 8));
g.top = Math.max(8, Math.min(point.clientY - this._drag.dy, window.innerHeight - g.height - 8));
g.top = Math.max(8, Math.min(point.clientY - this._drag.dy, safeHeight - g.height - 8));
this.win.style.left = `${g.left}px`;
this.win.style.top = `${g.top}px`;
}
@@ -345,8 +381,9 @@ export default class FloatingWindow extends Component {
if (event.cancelable) event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const g = this.geometry;
const safeHeight = this._safeViewport.height;
g.width = Math.max(320, Math.min(this._resize.w + (point.clientX - this._resize.x), window.innerWidth - g.left - 8));
g.height = Math.max(220, Math.min(this._resize.h + (point.clientY - this._resize.y), window.innerHeight - g.top - 8));
g.height = Math.max(220, Math.min(this._resize.h + (point.clientY - this._resize.y), safeHeight - g.top - 8));
this.win.style.width = `${g.width}px`;
this.win.style.height = `${g.height}px`;
this._scheduleResize();
+34 -2
View File
@@ -84,6 +84,12 @@ export default class DeviiTerminalElement extends FloatingWindow {
this._applyFont(this._readFont());
this._mobile.addEventListener("change", () => this._onViewportChange());
window.addEventListener("resize", () => this._clampWindow());
this._vv = window.visualViewport || null;
this._vvHandler = () => this._onVisualViewport2();
if (this._vv) {
this._vv.addEventListener("resize", this._vvHandler);
this._vv.addEventListener("scroll", this._vvHandler);
}
window.addEventListener("pagehide", () => this._captureFocusForUnload());
window.addEventListener("beforeunload", () => this._captureFocusForUnload());
this._identity = null;
@@ -416,18 +422,25 @@ export default class DeviiTerminalElement extends FloatingWindow {
this.win.style.width = "";
this.win.style.height = "";
}
const keyboardVisible = this._keyboardVisible;
this.classList.toggle("devii-keyboard-visible", keyboardVisible);
if (resolved === "fullscreen" && keyboardVisible && this._vv) {
this.win.style.height = `${this._vv.height}px`;
this.win.style.top = `${this._vv.offsetTop}px`;
}
this._syncControls();
this._persist();
}
_defaultGeometry() {
const safeHeight = this._safeViewport.height;
const width = Math.min(760, Math.round(window.innerWidth * 0.94));
const height = Math.min(600, Math.round(window.innerHeight * 0.82));
const height = Math.min(600, Math.round(safeHeight * 0.82));
return {
width,
height,
left: Math.max(12, window.innerWidth - width - 24),
top: Math.max(12, window.innerHeight - height - 24),
top: Math.max(12, safeHeight - height - 24),
};
}
@@ -436,6 +449,25 @@ export default class DeviiTerminalElement extends FloatingWindow {
this._setState("open");
}
_onVisualViewport2() {
if (this.state === "closed" || !this._vv) return;
this.classList.toggle("devii-keyboard-visible", this._keyboardVisible);
if (this.state === "fullscreen") {
this.win.style.height = `${this._vv.height}px`;
this.win.style.top = `${this._vv.offsetTop}px`;
}
this._ensureInputVisible();
}
_ensureInputVisible() {
window.requestAnimationFrame(() => {
if (document.activeElement === this.input && this.input.scrollIntoView) {
this.input.scrollIntoView({ block: "end" });
}
if (this.output) this.output.scrollTop = this.output.scrollHeight;
});
}
_onKey(event) {
if (event.key === "Enter") {
event.preventDefault();
+3 -2
View File
@@ -18,7 +18,7 @@
{% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
<span class="comment-time">{{ item.time_ago }}</span>
</div>
<div class="comment-text rendered-content" data-render>{{ item.comment['content'] }}</div>
<div class="comment-text rendered-content" data-render data-raw="{{ item.comment['content'] }}">{{ item.comment['content'] }}</div>
{% set attachments = item.get('attachments', []) %}
{% if attachments %}
{% include "_attachment_display.html" %}
@@ -26,7 +26,8 @@
<div class="comment-actions">
<button type="button" class="comment-action-btn" data-action="reply"{{ guest_disabled(user) }}><span class="icon">💬</span> Reply</button>
{% if owns(item.comment, user) %}
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form">
<button type="button" class="comment-action-btn" data-action="edit" data-edit-url="/comments/edit/{{ item.comment['uid'] }}"><span class="icon">✏️</span> Edit</button>
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form comment-delete-form" data-comment-uid="{{ item.comment['uid'] }}">
<button type="submit" class="comment-action-btn" data-confirm="Delete this comment?"><span class="icon">🗑️</span> Delete</button>
</form>
{% endif %}
@@ -0,0 +1,40 @@
{% extends "admin_base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/admin.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/planning.css') }}">
{% endblock %}
{% block admin_content %}
<div class="admin-header planning-header">
<div>
<h1>Ticket Planning</h1>
<p class="planning-subtitle">Generate a grouped, ordered planning report of every open ticket.</p>
</div>
{% if configured %}
<button type="button" class="btn btn-primary" data-planning-generate data-action="/issues/planning">
<span class="btn-spinner" aria-hidden="true"></span> Generate planning
</button>
{% endif %}
</div>
{% if not configured %}
<div class="empty-state">The issue tracker is not configured yet. Configure Gitea in Services first.</div>
{% else %}
<div class="planning-status" data-planning-status hidden>
<span class="planning-spinner" aria-hidden="true"></span>
<span class="planning-status-text">Generating the planning report...</span>
</div>
<div class="planning-result" data-planning-result hidden>
<div class="planning-actions">
<a class="btn btn-secondary" data-planning-download href="#" download>Download markdown</a>
<span class="planning-hint">Hover the report and use the Copy button to copy the markdown source.</span>
</div>
<dp-content class="planning-report" data-planning-report></dp-content>
</div>
<div class="planning-empty empty-state" data-planning-empty>
No planning generated yet. Click "Generate planning" to build one from the open tickets.
</div>
{% endif %}
{% endblock %}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0f0a1a">
<meta name="asset-version" content="{{ static_version }}">
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
@@ -16,6 +16,8 @@ Source: `static/js/components/AppContent.js`.
emitting unsanitised HTML.
- Equivalent to `data-render` on a server-rendered element, but as a self-contained element you
drop in directly.
- A copy button appears in the top-right corner on hover (and on focus) that copies the element's
original markdown source to the clipboard, mirroring the code-block copy button.
## Usage
+18
View File
@@ -49,6 +49,24 @@
</a>
</details>
</div>
<div class="sidebar-section">
<div class="sidebar-heading">Resources</div>
<div class="sidebar-nav">
<a href="/docs" class="sidebar-link">
<span class="icon">&#x1F4DA;</span>
Docs
</a>
<a href="/issues" class="sidebar-link">
<span class="icon">&#x1F41B;</span>
Issues
</a>
<a href="/devii/" class="sidebar-link" data-devii-open>
<span class="icon">&#x1F916;</span>
Devii
</a>
</div>
</div>
</aside>
<div class="feed-main">
+10
View File
@@ -2,6 +2,7 @@
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/gists.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/vendor/codemirror/codemirror.min.css') }}">
@@ -69,6 +70,15 @@
<div class="gist-card-desc">{{ item.gist['description'][:200] }}{% if item.gist['description']|length > 200 %}...{% endif %}</div>
{% endif %}
{% if item.recent_comments %}
{% from "_comment.html" import render_comment with context %}
<div class="post-card-comments">
{% for c in item.recent_comments %}
{{ render_comment(c, 0) }}
{% endfor %}
</div>
{% endif %}
<div class="gist-card-footer">
<div class="gist-author">
{% set _size = 20 %}{% set _size_class = "sm" %}{% set _user = item.author %}{% include "_avatar_link.html" %}
+1 -1
View File
@@ -65,7 +65,7 @@
{% if can_comment %}
<form method="POST" action="/issues/{{ issue.number }}/comment" class="comment-form issue-comment-form">
<textarea name="body" required maxlength="5000" placeholder="Add a comment (posted to the tracker)..." class="min-h-120"></textarea>
<textarea name="body" required maxlength="5000" placeholder="Add a comment (posted to the tracker)..." class="min-h-120" data-mention></textarea>
<div class="issue-comment-form-footer">
<button type="submit" class="btn btn-primary btn-sm"><span class="btn-spinner" aria-hidden="true"></span>Comment</button>
</div>
+11 -6
View File
@@ -7,11 +7,16 @@
<div class="issues-layout">
<div class="issues-header">
<h1>Issue Reports</h1>
{% if user %}
<button type="button" class="btn btn-primary btn-sm" data-modal="create-issue-modal"><span class="icon">&#x1F41B;</span> Report Issue</button>
{% else %}
<a href="/auth/login" class="btn btn-primary btn-sm login-required" title="Log in to participate"><span class="icon">&#x1F41B;</span> Report Issue</a>
{% endif %}
<div class="issues-header-actions">
{% if viewer_is_admin %}
<a href="/admin/issues/planning" class="btn btn-secondary btn-sm"><span class="icon">&#x1F5C2;&#xFE0F;</span> Generate planning</a>
{% endif %}
{% if user %}
<button type="button" class="btn btn-primary btn-sm" data-modal="create-issue-modal"><span class="icon">&#x1F41B;</span> Report Issue</button>
{% else %}
<a href="/auth/login" class="btn btn-primary btn-sm login-required" title="Log in to participate"><span class="icon">&#x1F41B;</span> Report Issue</a>
{% endif %}
</div>
</div>
<div class="issues-filters">
@@ -65,7 +70,7 @@
</div>
<div class="auth-field auth-field-gap">
<label for="issue-description">Description</label>
<textarea id="issue-description" name="description" required maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120"></textarea>
<textarea id="issue-description" name="description" required maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120" data-mention></textarea>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
+29 -26
View File
@@ -149,37 +149,40 @@
</section>
{% endif %}
<section class="landing-section landing-bots">
<section class="landing-section landing-help">
<div class="landing-section-header">
<h2>Self-Maintaining by Design</h2>
<a href="/docs/claude.html" class="landing-section-link">How it works &rarr;</a>
<h2>Build With Us</h2>
<a href="/docs/index.html" class="landing-section-link">Browse the docs &rarr;</a>
</div>
<p class="landing-bots-intro">DevPlace keeps its own house in order. A set of single-purpose Claude Code subagents reviews the codebase for security, audit coverage, documentation accuracy, and code quality, then fixes what it finds and proves the build still works.</p>
<div class="landing-bots-grid">
<div class="landing-bot-card">
<div class="landing-bot-icon">&#x1F6E1;</div>
<h3>Security</h3>
<p>Checks every route guard, ownership rule, and input limit across the app.</p>
<p class="landing-help-intro">DevPlace is built in the open and made to be extended. Read the docs, explore the API, file an issue, or let Devii - our in-platform AI developer - do the work with you. Everything you need to start contributing is one click away.</p>
<div class="landing-help-grid">
<div class="landing-help-card">
<div class="landing-help-icon">&#x1F4DA;</div>
<h3>Documentation</h3>
<p>Guides, the design system, component references, and deep internals - tiered for newcomers and operators alike.</p>
<a href="/docs/index.html" class="landing-help-link">Read the docs &rarr;</a>
</div>
<div class="landing-bot-card">
<div class="landing-bot-icon">&#x1F4DC;</div>
<h3>Audit</h3>
<p>Confirms every action that changes data leaves an audit trail.</p>
<div class="landing-help-card">
<div class="landing-help-icon">&#x1F9E9;</div>
<h3>API Reference</h3>
<p>A complete REST API with live OpenAPI schema. Build integrations, bots, and tools on top of every endpoint.</p>
<div class="landing-help-links">
<a href="/swagger">Swagger UI</a>
<a href="/openapi.json">OpenAPI JSON</a>
</div>
</div>
<div class="landing-bot-card">
<div class="landing-bot-icon">&#x1F4DA;</div>
<h3>Docs</h3>
<p>Keeps the documentation in step with the code, for the right audience.</p>
<div class="landing-help-card">
<div class="landing-help-icon">&#x1F41B;</div>
<h3>Contribute &amp; Report</h3>
<p>Found a bug or have an idea? Open an issue and follow it through to resolution in the integrated tracker.</p>
<a href="/issues" class="landing-help-link">Open the issue tracker &rarr;</a>
</div>
<div class="landing-bot-card">
<div class="landing-bot-icon">&#x267B;</div>
<h3>Quality</h3>
<p>Removes duplication and enforces the project's naming and style rules.</p>
</div>
<div class="landing-bot-card">
<div class="landing-bot-icon">&#x1F9EA;</div>
<h3>Tests</h3>
<p>Spots routes with no integration test and writes the missing coverage.</p>
<div class="landing-help-card landing-help-card-lead">
<div class="landing-help-icon">&#x1F916;</div>
<h3>Meet Devii</h3>
<p>Devii is your AI developer built into the platform. Ask it to write posts, manage projects, run containers, edit files, and guide you live on the page.</p>
<button type="button" class="landing-help-cta" data-devii-open>Launch Devii</button>
<a href="/devii/" class="landing-help-link">Open the full terminal &rarr;</a>
</div>
</div>
</section>
+10
View File
@@ -1,5 +1,7 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/news.css') }}">
{% endblock %}
{% block content %}
@@ -36,6 +38,14 @@
{% if item.article.get('description') %}
<p class="news-card-desc">{{ item.article['description'][:250] }}{% if item.article['description']|length > 250 %}...{% endif %}</p>
{% endif %}
{% if item.recent_comments %}
{% from "_comment.html" import render_comment with context %}
<div class="post-card-comments">
{% for c in item.recent_comments %}
{{ render_comment(c, 0) }}
{% endfor %}
</div>
{% endif %}
<a href="{{ item.article['url'] }}" target="_blank" rel="noopener" class="news-read-link">
Read on {{ item.article['source_name'] }} &#x2197;
</a>
+1 -1
View File
@@ -60,7 +60,7 @@
{% set _topics = topics %}{% set _selected = post['topic'] %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="edit-title">Title</label>
<input type="text" id="edit-title" name="title" maxlength="500" value="{{ post.get('title', '') }}">
<input type="text" id="edit-title" name="title" maxlength="500" value="{{ post.get('title') or '' }}">
</div>
<div class="auth-field auth-field-gap">
<label for="edit-content">Content</label>
+1 -1
View File
@@ -67,7 +67,7 @@
{% endif %}
<div class="project-detail-actions">
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">&#x1F4C1;</span> Files</a>
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">&#x1F4C1;</span> Files ({{ file_count }} files)</a>
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}"><span class="icon">&#x1F517;</span> Share</button>
{% if user %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
+10
View File
@@ -2,6 +2,7 @@
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
{% endblock %}
@@ -80,6 +81,15 @@
</div>
{% endif %}
</div>
{% if project.recent_comments %}
{% from "_comment.html" import render_comment with context %}
<div class="post-card-comments">
{% for c in project.recent_comments %}
{{ render_comment(c, 0) }}
{% endfor %}
</div>
{% endif %}
</div>
{% else %}
<div class="empty-state empty-state-full">No projects found. Create one!</div>