Reimplement compose and add tiling windows
This commit is contained in:
20
app/urls.py
20
app/urls.py
@@ -307,6 +307,11 @@ urlpatterns = [
|
|||||||
compose.ComposeWorkspaceContactsWidget.as_view(),
|
compose.ComposeWorkspaceContactsWidget.as_view(),
|
||||||
name="compose_workspace_contacts_widget",
|
name="compose_workspace_contacts_widget",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"compose/workspace/widget/history/",
|
||||||
|
compose.ComposeWorkspaceHistoryWidget.as_view(),
|
||||||
|
name="compose_workspace_history_widget",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"compose/send/",
|
"compose/send/",
|
||||||
compose.ComposeSend.as_view(),
|
compose.ComposeSend.as_view(),
|
||||||
@@ -422,26 +427,11 @@ urlpatterns = [
|
|||||||
tasks.TaskDetail.as_view(),
|
tasks.TaskDetail.as_view(),
|
||||||
name="tasks_task",
|
name="tasks_task",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"tasks/codex/submit/",
|
|
||||||
tasks.TaskCodexSubmit.as_view(),
|
|
||||||
name="tasks_codex_submit",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"settings/tasks/",
|
"settings/tasks/",
|
||||||
tasks.TaskSettings.as_view(),
|
tasks.TaskSettings.as_view(),
|
||||||
name="tasks_settings",
|
name="tasks_settings",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"settings/codex/",
|
|
||||||
tasks.CodexSettingsPage.as_view(),
|
|
||||||
name="codex_settings",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"settings/codex/approval/",
|
|
||||||
tasks.CodexApprovalAction.as_view(),
|
|
||||||
name="codex_approval",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"settings/behavioral/",
|
"settings/behavioral/",
|
||||||
availability.AvailabilitySettingsPage.as_view(),
|
availability.AvailabilitySettingsPage.as_view(),
|
||||||
|
|||||||
@@ -55,11 +55,9 @@ This is the repeatable process used in GIA to self-host third-party frontend ass
|
|||||||
- Font Awesome:
|
- Font Awesome:
|
||||||
- GIA templates use icon classes that are not safely replaceable with `@fortawesome/fontawesome-free`.
|
- GIA templates use icon classes that are not safely replaceable with `@fortawesome/fontawesome-free`.
|
||||||
- The existing Font Awesome Pro `site-assets` v6.1.1 bundle is self-hosted under `core/static/vendor/fontawesome/` instead.
|
- The existing Font Awesome Pro `site-assets` v6.1.1 bundle is self-hosted under `core/static/vendor/fontawesome/` instead.
|
||||||
- jQuery:
|
|
||||||
- Latest npm is 4.x, but GIA stays on `3.7.1` to avoid breaking older plugins.
|
|
||||||
- Bulma extensions:
|
- Bulma extensions:
|
||||||
- `bulma-calendar`, `bulma-tagsinput`, `bulma-switch`, `bulma-slider`, and `bulma-tooltip` were matched against Bulma's official extensions page before pinning.
|
- Former extensions were matched against Bulma's official extensions page before pinning.
|
||||||
- `bulma-calendar` and `bulma-tooltip` are deprecated on npm, but Bulma still points to the Wikiki ecosystem for these extensions, so they were kept and documented instead of replaced ad hoc.
|
- Unused extensions (`calendar`, `slider`, `tagsinput`, `switch`, and `tooltip`) were removed once the app no longer referenced them.
|
||||||
|
|
||||||
## Theme Strategy
|
## Theme Strategy
|
||||||
|
|
||||||
|
|||||||
@@ -21,174 +21,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "bulma_tooltip_css",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "bulma-tooltip",
|
|
||||||
"version": "3.0.2",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/Wikiki/bulma-tooltip#readme",
|
|
||||||
"official_url": "https://wikiki.github.io/elements/tooltip",
|
|
||||||
"purpose": "Official Bulma tooltip extension from Bulma's extensions page",
|
|
||||||
"notes": "",
|
|
||||||
"resolved": "https://registry.npmjs.org/bulma-tooltip/-/bulma-tooltip-3.0.2.tgz",
|
|
||||||
"dist_integrity": "sha512-CsT3APjhlZScskFg38n8HYL8oYNUHQtcu4sz6ERarxkUpBRbk9v0h/5KAvXeKapVSn2dp9l7bOGit5SECP8EWQ==",
|
|
||||||
"source_path": "dist/css/bulma-tooltip.min.css",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/css/bulma-tooltip.min.css",
|
|
||||||
"sha256": "5c79d12a40b3532aaec159faa0b85fd3d500e192467761b71e0bda0fd04f3076",
|
|
||||||
"sri_sha512": "sha512-SNDNIUvSYhnqDV9FFXaH/e0xZ6NzkG4Qm5dafLLf0PCMkzICKaOmMTgI3y2t2jZK+hAtP6A7UBcFqjWMhsujIg=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bulma_slider_css",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "bulma-slider",
|
|
||||||
"version": "2.0.5",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/Wikiki/bulma-slider#readme",
|
|
||||||
"official_url": "https://wikiki.github.io/form/slider",
|
|
||||||
"purpose": "Official Bulma slider extension from Bulma's extensions page",
|
|
||||||
"notes": "",
|
|
||||||
"resolved": "https://registry.npmjs.org/bulma-slider/-/bulma-slider-2.0.5.tgz",
|
|
||||||
"dist_integrity": "sha512-6woD/1E7q1o5bfEaQjNqpWZaCItC1oHe9bN15WYB2ELqz2gDaJYZkf+rlozGpAYOXQGDQGCCv3y+QuKjx6sQuw==",
|
|
||||||
"source_path": "dist/css/bulma-slider.min.css",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/css/bulma-slider.min.css",
|
|
||||||
"sha256": "f9d952627d388b8ba267e1388d6923274cf9e62e758d459c5a045f3933e9dc8a",
|
|
||||||
"sri_sha512": "sha512-9o5SkCRCA9thttRH3Gb5QXLxKdRiuRLdO6ToEPwRHGLXjrhTZwFj0rEHjrCcJvDN9/aNaWMpGOIEA2vZsHmEqw=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bulma_slider_js",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "bulma-slider",
|
|
||||||
"version": "2.0.5",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/Wikiki/bulma-slider#readme",
|
|
||||||
"official_url": "https://wikiki.github.io/form/slider",
|
|
||||||
"purpose": "Official Bulma slider extension runtime",
|
|
||||||
"notes": "",
|
|
||||||
"resolved": "https://registry.npmjs.org/bulma-slider/-/bulma-slider-2.0.5.tgz",
|
|
||||||
"dist_integrity": "sha512-6woD/1E7q1o5bfEaQjNqpWZaCItC1oHe9bN15WYB2ELqz2gDaJYZkf+rlozGpAYOXQGDQGCCv3y+QuKjx6sQuw==",
|
|
||||||
"source_path": "dist/js/bulma-slider.min.js",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/js/bulma-slider.min.js",
|
|
||||||
"sha256": "db68ebe154a25597913c5635f31500fe7a32e5a205fb9a98c9642d0c2de47d9e",
|
|
||||||
"sri_sha512": "sha512-WLKXHCsMXTSIPsmQShJRE6K4IzwvNkhwxr/Oo8N3z+kzjhGleHibspmWLTawNMdl2z9E23XK20+yvUTDZ+zeNQ=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bulma_calendar_css",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "bulma-calendar",
|
|
||||||
"version": "7.1.1",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://doc.mh-s.de/bulma-calendar",
|
|
||||||
"official_url": "https://wikiki.github.io/components/calendar",
|
|
||||||
"purpose": "Official Bulma calendar extension from Bulma's extensions page",
|
|
||||||
"notes": "",
|
|
||||||
"resolved": "https://registry.npmjs.org/bulma-calendar/-/bulma-calendar-7.1.1.tgz",
|
|
||||||
"dist_integrity": "sha512-E08i25KOfqMKBndgDF3y3eoQ0dUzVkgV9R53EDRM65GQUQKLzt8gcXVJYs3mYnpq6L3DiLuUt47Fl09tSv9OpA==",
|
|
||||||
"source_path": "src/demo/assets/css/bulma-calendar.min.css",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/css/bulma-calendar.min.css",
|
|
||||||
"sha256": "d18b488ca52584bcd6ea3fb84bf06380e47a3cd18660a235617da017d13ab269",
|
|
||||||
"sri_sha512": "sha512-IOnJQkgQpezPDPTJcRiWD7YVI3sF2RYzYDl4isbDT2geSaEHRQ615UN/8GhJbSkvqkKRZu8SBCQ7XwKMqsqLFQ=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bulma_calendar_js",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "bulma-calendar",
|
|
||||||
"version": "7.1.1",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://doc.mh-s.de/bulma-calendar",
|
|
||||||
"official_url": "https://wikiki.github.io/components/calendar",
|
|
||||||
"purpose": "Official Bulma calendar extension runtime",
|
|
||||||
"notes": "",
|
|
||||||
"resolved": "https://registry.npmjs.org/bulma-calendar/-/bulma-calendar-7.1.1.tgz",
|
|
||||||
"dist_integrity": "sha512-E08i25KOfqMKBndgDF3y3eoQ0dUzVkgV9R53EDRM65GQUQKLzt8gcXVJYs3mYnpq6L3DiLuUt47Fl09tSv9OpA==",
|
|
||||||
"source_path": "src/demo/assets/js/bulma-calendar.min.js",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/js/bulma-calendar.min.js",
|
|
||||||
"sha256": "58160c87c4d17f9d98ec366fe019492acde50efbc0297af7045547952b306680",
|
|
||||||
"sri_sha512": "sha512-kkEtEtypXzruevjkoxhyEkqkZBtlhK7s8zt7IV2yPabgBwy5xbKL9uWeCS37ldS9AaNTSnveWTu4ivUvGMJUWA=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bulma_tagsinput_css",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "bulma-tagsinput",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/Wikiki/bulma-tagsinput#readme",
|
|
||||||
"official_url": "https://wikiki.github.io/form/tagsinput",
|
|
||||||
"purpose": "Official Bulma tagsinput extension from Bulma's extensions page",
|
|
||||||
"notes": "",
|
|
||||||
"resolved": "https://registry.npmjs.org/bulma-tagsinput/-/bulma-tagsinput-2.0.0.tgz",
|
|
||||||
"dist_integrity": "sha512-BFvd0oaxgeWHOEh3d4cgETy5vpSSjRRBA9w+8TWEuhjFQg38Rb+3vjDCavL+udpdjf+dRV0SK5T4kYCXTOrz5A==",
|
|
||||||
"source_path": "dist/css/bulma-tagsinput.min.css",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/css/bulma-tagsinput.min.css",
|
|
||||||
"sha256": "8d1de24619c05ddf9045638b52059ab492d4887ce74119eed545d66af859da89",
|
|
||||||
"sri_sha512": "sha512-NWTkcDRubZ3pyXbZZLQBILuVsRFs8c6QGgnfe4dm5/d6yp50U+xdoCDLIcSo51fFy/GXH0O2Oed1Z1sF1faxDA=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bulma_tagsinput_js",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "bulma-tagsinput",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/Wikiki/bulma-tagsinput#readme",
|
|
||||||
"official_url": "https://wikiki.github.io/form/tagsinput",
|
|
||||||
"purpose": "Official Bulma tagsinput extension runtime",
|
|
||||||
"notes": "",
|
|
||||||
"resolved": "https://registry.npmjs.org/bulma-tagsinput/-/bulma-tagsinput-2.0.0.tgz",
|
|
||||||
"dist_integrity": "sha512-BFvd0oaxgeWHOEh3d4cgETy5vpSSjRRBA9w+8TWEuhjFQg38Rb+3vjDCavL+udpdjf+dRV0SK5T4kYCXTOrz5A==",
|
|
||||||
"source_path": "dist/js/bulma-tagsinput.min.js",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/js/bulma-tagsinput.min.js",
|
|
||||||
"sha256": "b355aa94ec519e374d7edf569e3dbde8bbe30ff3a193cb96f2930ee7815939d6",
|
|
||||||
"sri_sha512": "sha512-Je6J++MjmmpxF30JCmRwM2KiK3uWQBQtqiNCjwzEMJKExLaa0BqerlYNa/fJAl5Rra4hMgRZF2fzg+V2vjE4Kw=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bulma_switch_css",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "bulma-switch",
|
|
||||||
"version": "2.0.4",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/Wikiki/bulma-switch#readme",
|
|
||||||
"official_url": "https://wikiki.github.io/form/switch",
|
|
||||||
"purpose": "Official Bulma switch extension from Bulma's extensions page",
|
|
||||||
"notes": "",
|
|
||||||
"resolved": "https://registry.npmjs.org/bulma-switch/-/bulma-switch-2.0.4.tgz",
|
|
||||||
"dist_integrity": "sha512-kMu4H0Pr0VjvfsnT6viRDCgptUq0Rvy7y7PX6q+IHg1xUynsjszPjhAdal5ysAlCG5HNO+5YXxeiu92qYGQolw==",
|
|
||||||
"source_path": "dist/css/bulma-switch.min.css",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/css/bulma-switch.min.css",
|
|
||||||
"sha256": "f0460ddebdd95425a50590908503a170f5ff08b28bd53573c71791fc7cd1e6f5",
|
|
||||||
"sri_sha512": "sha512-zjrHYubQoNgDVqVKTyGjKcvIeQlduZTvXCvcBwQ0iqJYKLKiz9cuFAN7e98zfKqCTpI/EgFRBRcTwJw20yAFuw=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "gridstack_css",
|
"id": "gridstack_css",
|
||||||
"kind": "npm_file",
|
"kind": "npm_file",
|
||||||
@@ -231,27 +63,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "jquery_js",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "jquery",
|
|
||||||
"version": "3.7.1",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://jquery.com",
|
|
||||||
"official_url": "https://jquery.com",
|
|
||||||
"purpose": "Latest jQuery 3.x release for compatibility with legacy plugins",
|
|
||||||
"notes": "The latest npm release is jQuery 4.x, but this project still vendors 3.7.1 to avoid breaking older plugins.",
|
|
||||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
|
||||||
"dist_integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
|
|
||||||
"source_path": "dist/jquery.min.js",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/js/jquery.min.js",
|
|
||||||
"sha256": "fc9a93dd241f6b045cbff0481cf4e1901becd0e12fb45166a8f17f95823f0b1a",
|
|
||||||
"sri_sha512": "sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "htmx_js",
|
"id": "htmx_js",
|
||||||
"kind": "npm_file",
|
"kind": "npm_file",
|
||||||
@@ -273,48 +84,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "hyperscript_js",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "hyperscript.org",
|
|
||||||
"version": "0.9.14",
|
|
||||||
"license": "BSD 2-Clause",
|
|
||||||
"homepage": "https://hyperscript.org/",
|
|
||||||
"official_url": "https://hyperscript.org/",
|
|
||||||
"purpose": "_hyperscript runtime",
|
|
||||||
"notes": "",
|
|
||||||
"resolved": "https://registry.npmjs.org/hyperscript.org/-/hyperscript.org-0.9.14.tgz",
|
|
||||||
"dist_integrity": "sha512-ugmojsQQUMmXcnwaXYiYf8L3GbeANy/m59EmE/0Z6C5eQ52fOuSrvFkuEIejG9BdpbYB4iTtoYGqV99eYqDVMA==",
|
|
||||||
"source_path": "dist/_hyperscript.min.js",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/js/hyperscript.min.js",
|
|
||||||
"sha256": "3e834a3ffc0334fee54ecff4e37a6ae951cd83e6daa96651ca7cfd8f751ad4d2",
|
|
||||||
"sri_sha512": "sha512-l43sZzpnAddmYhJyfPrgv46XhJvA95gsA28/+eW4XZLSekQ8wlP68i9f22KGkRjY0HNiZrLc5MXGo4z/tM2QNA=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "magnet_js",
|
|
||||||
"kind": "npm_file",
|
|
||||||
"package": "@lf2com/magnet.js",
|
|
||||||
"version": "2.0.1",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/lf2com/magnet.js",
|
|
||||||
"official_url": "https://github.com/lf2com/magnet.js",
|
|
||||||
"purpose": "Magnet.js drag attraction component",
|
|
||||||
"notes": "",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lf2com/magnet.js/-/magnet.js-2.0.1.tgz",
|
|
||||||
"dist_integrity": "sha512-MDgv1s0aNOuftuhY9c9Ve6Yadkmn7G+Ww91cVciyHHMhPPdxTxX3XUSJXFYD3VraGFzcnI4uilik9/I76AsJEg==",
|
|
||||||
"source_path": "dist/magnet.min.js",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"path": "core/static/js/magnet.min.js",
|
|
||||||
"sha256": "05ff8858b5fb7b3ad2a618212571162e2108f580b08527716f4e63c648dcccb1",
|
|
||||||
"sri_sha512": "sha512-aoQ3V4iCM8zTcdMDSUTRG1K9wqZzmDSisuaCLQexk9DdFy92oWvTUoAfCVLnGzzJClst8PmtasZg219REwyNkw=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "fontawesome_bundle",
|
"id": "fontawesome_bundle",
|
||||||
"kind": "url_bundle",
|
"kind": "url_bundle",
|
||||||
|
|||||||
@@ -18,134 +18,6 @@ This report is generated by `scripts/vendor_frontend_assets.py` from `tools/fron
|
|||||||
- SRI sha512: `sha512-yh2RE0wZCVZeysGiqTwDTO/dKelCbS9bP2L94UvOFtl/FKXcNAje3Y2oBg/ZMZ3LS1sicYk4dYVGtDex75fvvA==`
|
- SRI sha512: `sha512-yh2RE0wZCVZeysGiqTwDTO/dKelCbS9bP2L94UvOFtl/FKXcNAje3Y2oBg/ZMZ3LS1sicYk4dYVGtDex75fvvA==`
|
||||||
|
|
||||||
|
|
||||||
## bulma_tooltip_css
|
|
||||||
|
|
||||||
- Source: `bulma-tooltip`
|
|
||||||
- Version: `3.0.2`
|
|
||||||
- Official URL: https://wikiki.github.io/elements/tooltip
|
|
||||||
- Homepage: https://github.com/Wikiki/bulma-tooltip#readme
|
|
||||||
- License: `MIT`
|
|
||||||
- Purpose: Official Bulma tooltip extension from Bulma's extensions page
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/bulma-tooltip/-/bulma-tooltip-3.0.2.tgz`
|
|
||||||
- Upstream package integrity: `sha512-CsT3APjhlZScskFg38n8HYL8oYNUHQtcu4sz6ERarxkUpBRbk9v0h/5KAvXeKapVSn2dp9l7bOGit5SECP8EWQ==`
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/css/bulma-tooltip.min.css`
|
|
||||||
- SHA-256: `5c79d12a40b3532aaec159faa0b85fd3d500e192467761b71e0bda0fd04f3076`
|
|
||||||
- SRI sha512: `sha512-SNDNIUvSYhnqDV9FFXaH/e0xZ6NzkG4Qm5dafLLf0PCMkzICKaOmMTgI3y2t2jZK+hAtP6A7UBcFqjWMhsujIg==`
|
|
||||||
|
|
||||||
|
|
||||||
## bulma_slider_css
|
|
||||||
|
|
||||||
- Source: `bulma-slider`
|
|
||||||
- Version: `2.0.5`
|
|
||||||
- Official URL: https://wikiki.github.io/form/slider
|
|
||||||
- Homepage: https://github.com/Wikiki/bulma-slider#readme
|
|
||||||
- License: `MIT`
|
|
||||||
- Purpose: Official Bulma slider extension from Bulma's extensions page
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/bulma-slider/-/bulma-slider-2.0.5.tgz`
|
|
||||||
- Upstream package integrity: `sha512-6woD/1E7q1o5bfEaQjNqpWZaCItC1oHe9bN15WYB2ELqz2gDaJYZkf+rlozGpAYOXQGDQGCCv3y+QuKjx6sQuw==`
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/css/bulma-slider.min.css`
|
|
||||||
- SHA-256: `f9d952627d388b8ba267e1388d6923274cf9e62e758d459c5a045f3933e9dc8a`
|
|
||||||
- SRI sha512: `sha512-9o5SkCRCA9thttRH3Gb5QXLxKdRiuRLdO6ToEPwRHGLXjrhTZwFj0rEHjrCcJvDN9/aNaWMpGOIEA2vZsHmEqw==`
|
|
||||||
|
|
||||||
|
|
||||||
## bulma_slider_js
|
|
||||||
|
|
||||||
- Source: `bulma-slider`
|
|
||||||
- Version: `2.0.5`
|
|
||||||
- Official URL: https://wikiki.github.io/form/slider
|
|
||||||
- Homepage: https://github.com/Wikiki/bulma-slider#readme
|
|
||||||
- License: `MIT`
|
|
||||||
- Purpose: Official Bulma slider extension runtime
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/bulma-slider/-/bulma-slider-2.0.5.tgz`
|
|
||||||
- Upstream package integrity: `sha512-6woD/1E7q1o5bfEaQjNqpWZaCItC1oHe9bN15WYB2ELqz2gDaJYZkf+rlozGpAYOXQGDQGCCv3y+QuKjx6sQuw==`
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/js/bulma-slider.min.js`
|
|
||||||
- SHA-256: `db68ebe154a25597913c5635f31500fe7a32e5a205fb9a98c9642d0c2de47d9e`
|
|
||||||
- SRI sha512: `sha512-WLKXHCsMXTSIPsmQShJRE6K4IzwvNkhwxr/Oo8N3z+kzjhGleHibspmWLTawNMdl2z9E23XK20+yvUTDZ+zeNQ==`
|
|
||||||
|
|
||||||
|
|
||||||
## bulma_calendar_css
|
|
||||||
|
|
||||||
- Source: `bulma-calendar`
|
|
||||||
- Version: `7.1.1`
|
|
||||||
- Official URL: https://wikiki.github.io/components/calendar
|
|
||||||
- Homepage: https://doc.mh-s.de/bulma-calendar
|
|
||||||
- License: `MIT`
|
|
||||||
- Purpose: Official Bulma calendar extension from Bulma's extensions page
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/bulma-calendar/-/bulma-calendar-7.1.1.tgz`
|
|
||||||
- Upstream package integrity: `sha512-E08i25KOfqMKBndgDF3y3eoQ0dUzVkgV9R53EDRM65GQUQKLzt8gcXVJYs3mYnpq6L3DiLuUt47Fl09tSv9OpA==`
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/css/bulma-calendar.min.css`
|
|
||||||
- SHA-256: `d18b488ca52584bcd6ea3fb84bf06380e47a3cd18660a235617da017d13ab269`
|
|
||||||
- SRI sha512: `sha512-IOnJQkgQpezPDPTJcRiWD7YVI3sF2RYzYDl4isbDT2geSaEHRQ615UN/8GhJbSkvqkKRZu8SBCQ7XwKMqsqLFQ==`
|
|
||||||
|
|
||||||
|
|
||||||
## bulma_calendar_js
|
|
||||||
|
|
||||||
- Source: `bulma-calendar`
|
|
||||||
- Version: `7.1.1`
|
|
||||||
- Official URL: https://wikiki.github.io/components/calendar
|
|
||||||
- Homepage: https://doc.mh-s.de/bulma-calendar
|
|
||||||
- License: `MIT`
|
|
||||||
- Purpose: Official Bulma calendar extension runtime
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/bulma-calendar/-/bulma-calendar-7.1.1.tgz`
|
|
||||||
- Upstream package integrity: `sha512-E08i25KOfqMKBndgDF3y3eoQ0dUzVkgV9R53EDRM65GQUQKLzt8gcXVJYs3mYnpq6L3DiLuUt47Fl09tSv9OpA==`
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/js/bulma-calendar.min.js`
|
|
||||||
- SHA-256: `58160c87c4d17f9d98ec366fe019492acde50efbc0297af7045547952b306680`
|
|
||||||
- SRI sha512: `sha512-kkEtEtypXzruevjkoxhyEkqkZBtlhK7s8zt7IV2yPabgBwy5xbKL9uWeCS37ldS9AaNTSnveWTu4ivUvGMJUWA==`
|
|
||||||
|
|
||||||
|
|
||||||
## bulma_tagsinput_css
|
|
||||||
|
|
||||||
- Source: `bulma-tagsinput`
|
|
||||||
- Version: `2.0.0`
|
|
||||||
- Official URL: https://wikiki.github.io/form/tagsinput
|
|
||||||
- Homepage: https://github.com/Wikiki/bulma-tagsinput#readme
|
|
||||||
- License: `MIT`
|
|
||||||
- Purpose: Official Bulma tagsinput extension from Bulma's extensions page
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/bulma-tagsinput/-/bulma-tagsinput-2.0.0.tgz`
|
|
||||||
- Upstream package integrity: `sha512-BFvd0oaxgeWHOEh3d4cgETy5vpSSjRRBA9w+8TWEuhjFQg38Rb+3vjDCavL+udpdjf+dRV0SK5T4kYCXTOrz5A==`
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/css/bulma-tagsinput.min.css`
|
|
||||||
- SHA-256: `8d1de24619c05ddf9045638b52059ab492d4887ce74119eed545d66af859da89`
|
|
||||||
- SRI sha512: `sha512-NWTkcDRubZ3pyXbZZLQBILuVsRFs8c6QGgnfe4dm5/d6yp50U+xdoCDLIcSo51fFy/GXH0O2Oed1Z1sF1faxDA==`
|
|
||||||
|
|
||||||
|
|
||||||
## bulma_tagsinput_js
|
|
||||||
|
|
||||||
- Source: `bulma-tagsinput`
|
|
||||||
- Version: `2.0.0`
|
|
||||||
- Official URL: https://wikiki.github.io/form/tagsinput
|
|
||||||
- Homepage: https://github.com/Wikiki/bulma-tagsinput#readme
|
|
||||||
- License: `MIT`
|
|
||||||
- Purpose: Official Bulma tagsinput extension runtime
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/bulma-tagsinput/-/bulma-tagsinput-2.0.0.tgz`
|
|
||||||
- Upstream package integrity: `sha512-BFvd0oaxgeWHOEh3d4cgETy5vpSSjRRBA9w+8TWEuhjFQg38Rb+3vjDCavL+udpdjf+dRV0SK5T4kYCXTOrz5A==`
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/js/bulma-tagsinput.min.js`
|
|
||||||
- SHA-256: `b355aa94ec519e374d7edf569e3dbde8bbe30ff3a193cb96f2930ee7815939d6`
|
|
||||||
- SRI sha512: `sha512-Je6J++MjmmpxF30JCmRwM2KiK3uWQBQtqiNCjwzEMJKExLaa0BqerlYNa/fJAl5Rra4hMgRZF2fzg+V2vjE4Kw==`
|
|
||||||
|
|
||||||
|
|
||||||
## bulma_switch_css
|
|
||||||
|
|
||||||
- Source: `bulma-switch`
|
|
||||||
- Version: `2.0.4`
|
|
||||||
- Official URL: https://wikiki.github.io/form/switch
|
|
||||||
- Homepage: https://github.com/Wikiki/bulma-switch#readme
|
|
||||||
- License: `MIT`
|
|
||||||
- Purpose: Official Bulma switch extension from Bulma's extensions page
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/bulma-switch/-/bulma-switch-2.0.4.tgz`
|
|
||||||
- Upstream package integrity: `sha512-kMu4H0Pr0VjvfsnT6viRDCgptUq0Rvy7y7PX6q+IHg1xUynsjszPjhAdal5ysAlCG5HNO+5YXxeiu92qYGQolw==`
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/css/bulma-switch.min.css`
|
|
||||||
- SHA-256: `f0460ddebdd95425a50590908503a170f5ff08b28bd53573c71791fc7cd1e6f5`
|
|
||||||
- SRI sha512: `sha512-zjrHYubQoNgDVqVKTyGjKcvIeQlduZTvXCvcBwQ0iqJYKLKiz9cuFAN7e98zfKqCTpI/EgFRBRcTwJw20yAFuw==`
|
|
||||||
|
|
||||||
|
|
||||||
## gridstack_css
|
## gridstack_css
|
||||||
|
|
||||||
- Source: `gridstack`
|
- Source: `gridstack`
|
||||||
@@ -178,23 +50,6 @@ This report is generated by `scripts/vendor_frontend_assets.py` from `tools/fron
|
|||||||
- SRI sha512: `sha512-djBPxwvBhDep1SvOhliatweHMORhVO3HabrfBjaW6nYsa7UcJYHty31x42m4HBSJXcJSQdoEgRPLVYGGIuIaDQ==`
|
- SRI sha512: `sha512-djBPxwvBhDep1SvOhliatweHMORhVO3HabrfBjaW6nYsa7UcJYHty31x42m4HBSJXcJSQdoEgRPLVYGGIuIaDQ==`
|
||||||
|
|
||||||
|
|
||||||
## jquery_js
|
|
||||||
|
|
||||||
- Source: `jquery`
|
|
||||||
- Version: `3.7.1`
|
|
||||||
- Official URL: https://jquery.com
|
|
||||||
- Homepage: https://jquery.com
|
|
||||||
- License: `MIT`
|
|
||||||
- Purpose: Latest jQuery 3.x release for compatibility with legacy plugins
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz`
|
|
||||||
- Upstream package integrity: `sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==`
|
|
||||||
- Notes: The latest npm release is jQuery 4.x, but this project still vendors 3.7.1 to avoid breaking older plugins.
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/js/jquery.min.js`
|
|
||||||
- SHA-256: `fc9a93dd241f6b045cbff0481cf4e1901becd0e12fb45166a8f17f95823f0b1a`
|
|
||||||
- SRI sha512: `sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==`
|
|
||||||
|
|
||||||
|
|
||||||
## htmx_js
|
## htmx_js
|
||||||
|
|
||||||
- Source: `htmx.org`
|
- Source: `htmx.org`
|
||||||
@@ -211,38 +66,6 @@ This report is generated by `scripts/vendor_frontend_assets.py` from `tools/fron
|
|||||||
- SRI sha512: `sha512-CGXFnDNv5q48ciFeIyWFcfZhqYW0sSBiPO+HZDO3XLM+p8xjhezz5CCxtkXVDKfCbvF+iUhel7xoeSp19o7x7g==`
|
- SRI sha512: `sha512-CGXFnDNv5q48ciFeIyWFcfZhqYW0sSBiPO+HZDO3XLM+p8xjhezz5CCxtkXVDKfCbvF+iUhel7xoeSp19o7x7g==`
|
||||||
|
|
||||||
|
|
||||||
## hyperscript_js
|
|
||||||
|
|
||||||
- Source: `hyperscript.org`
|
|
||||||
- Version: `0.9.14`
|
|
||||||
- Official URL: https://hyperscript.org/
|
|
||||||
- Homepage: https://hyperscript.org/
|
|
||||||
- License: `BSD 2-Clause`
|
|
||||||
- Purpose: _hyperscript runtime
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/hyperscript.org/-/hyperscript.org-0.9.14.tgz`
|
|
||||||
- Upstream package integrity: `sha512-ugmojsQQUMmXcnwaXYiYf8L3GbeANy/m59EmE/0Z6C5eQ52fOuSrvFkuEIejG9BdpbYB4iTtoYGqV99eYqDVMA==`
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/js/hyperscript.min.js`
|
|
||||||
- SHA-256: `3e834a3ffc0334fee54ecff4e37a6ae951cd83e6daa96651ca7cfd8f751ad4d2`
|
|
||||||
- SRI sha512: `sha512-l43sZzpnAddmYhJyfPrgv46XhJvA95gsA28/+eW4XZLSekQ8wlP68i9f22KGkRjY0HNiZrLc5MXGo4z/tM2QNA==`
|
|
||||||
|
|
||||||
|
|
||||||
## magnet_js
|
|
||||||
|
|
||||||
- Source: `@lf2com/magnet.js`
|
|
||||||
- Version: `2.0.1`
|
|
||||||
- Official URL: https://github.com/lf2com/magnet.js
|
|
||||||
- Homepage: https://github.com/lf2com/magnet.js
|
|
||||||
- License: `MIT`
|
|
||||||
- Purpose: Magnet.js drag attraction component
|
|
||||||
- Resolved tarball: `https://registry.npmjs.org/@lf2com/magnet.js/-/magnet.js-2.0.1.tgz`
|
|
||||||
- Upstream package integrity: `sha512-MDgv1s0aNOuftuhY9c9Ve6Yadkmn7G+Ww91cVciyHHMhPPdxTxX3XUSJXFYD3VraGFzcnI4uilik9/I76AsJEg==`
|
|
||||||
- Local targets:
|
|
||||||
- `core/static/js/magnet.min.js`
|
|
||||||
- SHA-256: `05ff8858b5fb7b3ad2a618212571162e2108f580b08527716f4e63c648dcccb1`
|
|
||||||
- SRI sha512: `sha512-aoQ3V4iCM8zTcdMDSUTRG1K9wqZzmDSisuaCLQexk9DdFy92oWvTUoAfCVLnGzzJClst8PmtasZg219REwyNkw==`
|
|
||||||
|
|
||||||
|
|
||||||
## fontawesome_bundle
|
## fontawesome_bundle
|
||||||
|
|
||||||
- Source: `https://site-assets.fontawesome.com/releases/v6.1.1/css/all.css`
|
- Source: `https://site-assets.fontawesome.com/releases/v6.1.1/css/all.css`
|
||||||
|
|||||||
157
artifacts/workspace_interface_plan.md
Normal file
157
artifacts/workspace_interface_plan.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# GIA Workspace Interface Plan
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Replace page-by-page navigation with a workspace shell that can open any major surface as a widget.
|
||||||
|
- Keep transport quirks behind shared adapters so UI code deals in people, sessions, messages, PS, MS, PSC, and MSC.
|
||||||
|
- Make widget loading, live updates, and history browsing shared primitives instead of page-specific implementations.
|
||||||
|
|
||||||
|
## Interface Map
|
||||||
|
|
||||||
|
### 1. Workspace Shell
|
||||||
|
|
||||||
|
- One grid workspace shell owns widget layout, persistence, focus, close, restore, and launch.
|
||||||
|
- A launcher surface replaces the current nav-as-destination approach.
|
||||||
|
- Every major area can render as:
|
||||||
|
- page
|
||||||
|
- widget
|
||||||
|
- embeddable fragment
|
||||||
|
|
||||||
|
### 2. Core Widgets
|
||||||
|
|
||||||
|
- Compose widget:
|
||||||
|
- simple thread viewer
|
||||||
|
- simple outbound composer
|
||||||
|
- reply target
|
||||||
|
- attachment strip
|
||||||
|
- live MS/PS chips
|
||||||
|
- Contact launcher widget:
|
||||||
|
- search
|
||||||
|
- recent contacts
|
||||||
|
- open thread widget
|
||||||
|
- create/match contact
|
||||||
|
- Message history browser:
|
||||||
|
- long-range session browsing
|
||||||
|
- filters by service, person, session, direction, attachments, date
|
||||||
|
- open selected history in a compose/thread widget
|
||||||
|
- Person intelligence widget:
|
||||||
|
- current PS summary
|
||||||
|
- current MS summary
|
||||||
|
- PSC/MSC highlights
|
||||||
|
- links into evidence and history
|
||||||
|
- Session evidence widget:
|
||||||
|
- timeline of events
|
||||||
|
- annotated durations
|
||||||
|
- state transitions
|
||||||
|
- Insight workbench widget:
|
||||||
|
- hypothesis cards
|
||||||
|
- mitigation planning
|
||||||
|
- evidence drill-down
|
||||||
|
- Help/reference widget:
|
||||||
|
- PS/MS glossary
|
||||||
|
- PSC/MSC explanations
|
||||||
|
- research basis
|
||||||
|
|
||||||
|
### 3. Behavioral Surfaces
|
||||||
|
|
||||||
|
- PS lane:
|
||||||
|
- unavailable -> available -> typing -> typing_stopped
|
||||||
|
- duration chips and transition stats
|
||||||
|
- MS lane:
|
||||||
|
- sent -> delivered -> read -> responded
|
||||||
|
- duration chips and transition stats
|
||||||
|
- PSC surface:
|
||||||
|
- correlation patterns within PS sequences
|
||||||
|
- return-window priority, abandoned typing, repeated hesitation
|
||||||
|
- MSC surface:
|
||||||
|
- correlation patterns within MS sequences
|
||||||
|
- reply delay shifts, asymmetry, reciprocity, timing escalation
|
||||||
|
- Combined inference surface:
|
||||||
|
- PSC + MSC combinations with evidence, confidence, and caveats
|
||||||
|
|
||||||
|
## Shared Technical Primitives
|
||||||
|
|
||||||
|
### Widget Contract
|
||||||
|
|
||||||
|
Every widget-capable surface should declare:
|
||||||
|
|
||||||
|
- title
|
||||||
|
- icon
|
||||||
|
- source URL
|
||||||
|
- refresh URL
|
||||||
|
- websocket topic or polling policy
|
||||||
|
- default grid dimensions
|
||||||
|
- optional launch context
|
||||||
|
|
||||||
|
### Shared Client Modules
|
||||||
|
|
||||||
|
- `workspace-shell`
|
||||||
|
- open widget from URL
|
||||||
|
- replace/update widget
|
||||||
|
- compact grid
|
||||||
|
- persist layout
|
||||||
|
- `widget-loader`
|
||||||
|
- HTMX/bootstrap helper for hidden widget loads
|
||||||
|
- one primitive reused by page bootstraps and launch actions
|
||||||
|
- `live-channel`
|
||||||
|
- shared websocket subscription lifecycle
|
||||||
|
- reconnect and backoff
|
||||||
|
- channel-to-widget routing
|
||||||
|
- `history-browser`
|
||||||
|
- filters, pagination, range selection, transcript fetch
|
||||||
|
- `compose-client`
|
||||||
|
- message list render/update
|
||||||
|
- outbound send
|
||||||
|
- attachment handling
|
||||||
|
- reply targeting
|
||||||
|
|
||||||
|
### Shared Server Patterns
|
||||||
|
|
||||||
|
- Each surface provides `page`, `widget`, and `fragment` modes from one context builder where possible.
|
||||||
|
- Widget launch URLs are generated centrally, not manually concatenated in templates.
|
||||||
|
- Behavioral computations read shared PS/MS event abstractions, not transport names.
|
||||||
|
- Live updates publish transport-neutral event payloads.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1. Workspace Foundation
|
||||||
|
|
||||||
|
- Extract workspace shell logic from inline page scripts into shared assets.
|
||||||
|
- Standardize the hidden widget loader include.
|
||||||
|
- Slim launcher widgets so they do not dump entire datasets into one partial.
|
||||||
|
- Add a reusable message history browser endpoint and widget shell.
|
||||||
|
|
||||||
|
### Phase 2. Compose Rewrite
|
||||||
|
|
||||||
|
- Keep compose to:
|
||||||
|
- thread
|
||||||
|
- send
|
||||||
|
- reply
|
||||||
|
- attachment preview
|
||||||
|
- live state indicators
|
||||||
|
- Move history/export/receipt/debug extras out into dedicated widgets.
|
||||||
|
- Keep page mode and widget mode on the same client code.
|
||||||
|
|
||||||
|
### Phase 3. Behavioral Workspace
|
||||||
|
|
||||||
|
- Add PS/MS event browser widgets.
|
||||||
|
- Add PSC/MSC evidence widgets with drill-down into raw events.
|
||||||
|
- Add person/session dashboards that combine evidence instead of hiding it behind static pages.
|
||||||
|
|
||||||
|
### Phase 4. Live Update Layer
|
||||||
|
|
||||||
|
- Replace page-local polling with a shared websocket abstraction.
|
||||||
|
- Route updates by widget topic:
|
||||||
|
- compose thread
|
||||||
|
- message state
|
||||||
|
- presence state
|
||||||
|
- analysis refresh
|
||||||
|
- Keep HTMX for request/response actions and server-rendered fragments.
|
||||||
|
|
||||||
|
## Immediate Debt Kill List
|
||||||
|
|
||||||
|
- Large launcher partials that dump whole universes of contacts or objects.
|
||||||
|
- Inline workspace shell JS in templates.
|
||||||
|
- Page-specific widget bootstraps that repeat the same hidden HTMX loader block.
|
||||||
|
- Mixed transport-specific UI conditionals in templates.
|
||||||
|
- Heavy detail views that combine history, inference, controls, and diagnostics in one render.
|
||||||
@@ -9,8 +9,6 @@ from core.commands.handlers.bp import (
|
|||||||
bp_subcommands_enabled,
|
bp_subcommands_enabled,
|
||||||
bp_trigger_matches,
|
bp_trigger_matches,
|
||||||
)
|
)
|
||||||
from core.commands.handlers.claude import ClaudeCommandHandler, claude_trigger_matches
|
|
||||||
from core.commands.handlers.codex import CodexCommandHandler, codex_trigger_matches
|
|
||||||
from core.commands.policies import ensure_variant_policies_for_profile
|
from core.commands.policies import ensure_variant_policies_for_profile
|
||||||
from core.commands.registry import get as get_handler
|
from core.commands.registry import get as get_handler
|
||||||
from core.commands.registry import register
|
from core.commands.registry import register
|
||||||
@@ -29,6 +27,7 @@ from core.util import logs
|
|||||||
log = logs.get_logger("command_engine")
|
log = logs.get_logger("command_engine")
|
||||||
|
|
||||||
_REGISTERED = False
|
_REGISTERED = False
|
||||||
|
_SUPPORTED_PROFILE_SLUGS = {"bp"}
|
||||||
|
|
||||||
|
|
||||||
def _channel_variants(service: str, channel_identifier: str) -> list[str]:
|
def _channel_variants(service: str, channel_identifier: str) -> list[str]:
|
||||||
@@ -177,59 +176,9 @@ def _ensure_bp_profile(user_id: int) -> CommandProfile:
|
|||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
|
||||||
def _ensure_codex_profile(user_id: int) -> CommandProfile:
|
|
||||||
profile, _ = CommandProfile.objects.get_or_create(
|
|
||||||
user_id=user_id,
|
|
||||||
slug="codex",
|
|
||||||
defaults={
|
|
||||||
"name": "Codex",
|
|
||||||
"enabled": True,
|
|
||||||
"trigger_token": ".codex",
|
|
||||||
"reply_required": False,
|
|
||||||
"exact_match_only": False,
|
|
||||||
"window_scope": "conversation",
|
|
||||||
"visibility_mode": "status_in_source",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not profile.enabled:
|
|
||||||
profile.enabled = True
|
|
||||||
profile.save(update_fields=["enabled", "updated_at"])
|
|
||||||
if str(profile.trigger_token or "").strip() != ".codex":
|
|
||||||
profile.trigger_token = ".codex"
|
|
||||||
profile.save(update_fields=["trigger_token", "updated_at"])
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_claude_profile(user_id: int) -> CommandProfile:
|
|
||||||
profile, _ = CommandProfile.objects.get_or_create(
|
|
||||||
user_id=user_id,
|
|
||||||
slug="claude",
|
|
||||||
defaults={
|
|
||||||
"name": "Claude",
|
|
||||||
"enabled": True,
|
|
||||||
"trigger_token": ".claude",
|
|
||||||
"reply_required": False,
|
|
||||||
"exact_match_only": False,
|
|
||||||
"window_scope": "conversation",
|
|
||||||
"visibility_mode": "status_in_source",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not profile.enabled:
|
|
||||||
profile.enabled = True
|
|
||||||
profile.save(update_fields=["enabled", "updated_at"])
|
|
||||||
if str(profile.trigger_token or "").strip() != ".claude":
|
|
||||||
profile.trigger_token = ".claude"
|
|
||||||
profile.save(update_fields=["trigger_token", "updated_at"])
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_profile_for_slug(user_id: int, slug: str) -> CommandProfile | None:
|
def _ensure_profile_for_slug(user_id: int, slug: str) -> CommandProfile | None:
|
||||||
if slug == "bp":
|
if slug == "bp":
|
||||||
return _ensure_bp_profile(user_id)
|
return _ensure_bp_profile(user_id)
|
||||||
if slug == "codex":
|
|
||||||
return _ensure_codex_profile(user_id)
|
|
||||||
if slug == "claude":
|
|
||||||
return _ensure_claude_profile(user_id)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -237,10 +186,6 @@ def _detected_bootstrap_slugs(message_text: str) -> list[str]:
|
|||||||
slugs: list[str] = []
|
slugs: list[str] = []
|
||||||
if bp_trigger_matches(message_text, ".bp", False):
|
if bp_trigger_matches(message_text, ".bp", False):
|
||||||
slugs.append("bp")
|
slugs.append("bp")
|
||||||
if codex_trigger_matches(message_text, ".codex", False):
|
|
||||||
slugs.append("codex")
|
|
||||||
if claude_trigger_matches(message_text, ".claude", False):
|
|
||||||
slugs.append("claude")
|
|
||||||
return slugs
|
return slugs
|
||||||
|
|
||||||
|
|
||||||
@@ -306,8 +251,6 @@ def ensure_handlers_registered():
|
|||||||
if _REGISTERED:
|
if _REGISTERED:
|
||||||
return
|
return
|
||||||
register(BPCommandHandler())
|
register(BPCommandHandler())
|
||||||
register(CodexCommandHandler())
|
|
||||||
register(ClaudeCommandHandler())
|
|
||||||
_REGISTERED = True
|
_REGISTERED = True
|
||||||
|
|
||||||
|
|
||||||
@@ -337,6 +280,7 @@ async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
|||||||
CommandProfile.objects.filter(
|
CommandProfile.objects.filter(
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
|
slug__in=_SUPPORTED_PROFILE_SLUGS,
|
||||||
channel_bindings__enabled=True,
|
channel_bindings__enabled=True,
|
||||||
channel_bindings__direction="ingress",
|
channel_bindings__direction="ingress",
|
||||||
channel_bindings__service=ctx.service,
|
channel_bindings__service=ctx.service,
|
||||||
@@ -370,6 +314,7 @@ async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
|||||||
CommandProfile.objects.filter(
|
CommandProfile.objects.filter(
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
|
slug__in=_SUPPORTED_PROFILE_SLUGS,
|
||||||
channel_bindings__enabled=True,
|
channel_bindings__enabled=True,
|
||||||
channel_bindings__direction="ingress",
|
channel_bindings__direction="ingress",
|
||||||
channel_bindings__service=fallback_service,
|
channel_bindings__service=fallback_service,
|
||||||
@@ -387,18 +332,6 @@ def _matches_trigger(profile: CommandProfile, text: str) -> bool:
|
|||||||
trigger_token=profile.trigger_token,
|
trigger_token=profile.trigger_token,
|
||||||
exact_match_only=profile.exact_match_only,
|
exact_match_only=profile.exact_match_only,
|
||||||
)
|
)
|
||||||
if profile.slug == "codex":
|
|
||||||
return codex_trigger_matches(
|
|
||||||
message_text=text,
|
|
||||||
trigger_token=profile.trigger_token,
|
|
||||||
exact_match_only=profile.exact_match_only,
|
|
||||||
)
|
|
||||||
if profile.slug == "claude":
|
|
||||||
return claude_trigger_matches(
|
|
||||||
message_text=text,
|
|
||||||
trigger_token=profile.trigger_token,
|
|
||||||
exact_match_only=profile.exact_match_only,
|
|
||||||
)
|
|
||||||
body = str(text or "").strip()
|
body = str(text or "").strip()
|
||||||
trigger = str(profile.trigger_token or "").strip()
|
trigger = str(profile.trigger_token or "").strip()
|
||||||
if not trigger:
|
if not trigger:
|
||||||
|
|||||||
@@ -1,629 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from core.commands.base import CommandContext, CommandHandler, CommandResult
|
|
||||||
from core.commands.delivery import post_status_in_source
|
|
||||||
from core.messaging.text_export import plain_text_blob
|
|
||||||
from core.models import (
|
|
||||||
ChatTaskSource,
|
|
||||||
CodexPermissionRequest,
|
|
||||||
CodexRun,
|
|
||||||
CommandProfile,
|
|
||||||
DerivedTask,
|
|
||||||
ExternalSyncEvent,
|
|
||||||
Message,
|
|
||||||
TaskProject,
|
|
||||||
TaskProviderConfig,
|
|
||||||
)
|
|
||||||
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
|
|
||||||
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
|
|
||||||
|
|
||||||
_CLAUDE_DEFAULT_RE = re.compile(
|
|
||||||
r"^\s*(?:\.claude\b|#claude#?)(?P<body>.*)$",
|
|
||||||
re.IGNORECASE | re.DOTALL,
|
|
||||||
)
|
|
||||||
_CLAUDE_PLAN_RE = re.compile(
|
|
||||||
r"^\s*(?:\.claude\s+plan\b|#claude\s+plan#?)(?P<body>.*)$",
|
|
||||||
re.IGNORECASE | re.DOTALL,
|
|
||||||
)
|
|
||||||
_CLAUDE_STATUS_RE = re.compile(
|
|
||||||
r"^\s*(?:\.claude\s+status\b|#claude\s+status#?)\s*$", re.IGNORECASE
|
|
||||||
)
|
|
||||||
_CLAUDE_APPROVE_DENY_RE = re.compile(
|
|
||||||
r"^\s*(?:\.claude|#claude)\s+(?P<action>approve|deny)\s+(?P<approval_key>[A-Za-z0-9._:-]+)#?\s*$",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_PROJECT_TOKEN_RE = re.compile(r"\[\s*project\s*:\s*([^\]]+)\]", re.IGNORECASE)
|
|
||||||
_REFERENCE_RE = re.compile(r"(?<!\w)#([A-Za-z0-9_-]+)\b")
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeParsedCommand(dict):
|
|
||||||
@property
|
|
||||||
def command(self) -> str | None:
|
|
||||||
value = self.get("command")
|
|
||||||
return str(value) if value else None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def body_text(self) -> str:
|
|
||||||
return str(self.get("body_text") or "")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def approval_key(self) -> str:
|
|
||||||
return str(self.get("approval_key") or "")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_claude_command(text: str) -> ClaudeParsedCommand:
|
|
||||||
body = str(text or "")
|
|
||||||
m = _CLAUDE_APPROVE_DENY_RE.match(body)
|
|
||||||
if m:
|
|
||||||
return ClaudeParsedCommand(
|
|
||||||
command=str(m.group("action") or "").strip().lower(),
|
|
||||||
body_text="",
|
|
||||||
approval_key=str(m.group("approval_key") or "").strip(),
|
|
||||||
)
|
|
||||||
if _CLAUDE_STATUS_RE.match(body):
|
|
||||||
return ClaudeParsedCommand(command="status", body_text="", approval_key="")
|
|
||||||
m = _CLAUDE_PLAN_RE.match(body)
|
|
||||||
if m:
|
|
||||||
return ClaudeParsedCommand(
|
|
||||||
command="plan",
|
|
||||||
body_text=str(m.group("body") or "").strip(),
|
|
||||||
approval_key="",
|
|
||||||
)
|
|
||||||
m = _CLAUDE_DEFAULT_RE.match(body)
|
|
||||||
if m:
|
|
||||||
return ClaudeParsedCommand(
|
|
||||||
command="default",
|
|
||||||
body_text=str(m.group("body") or "").strip(),
|
|
||||||
approval_key="",
|
|
||||||
)
|
|
||||||
return ClaudeParsedCommand(command=None, body_text="", approval_key="")
|
|
||||||
|
|
||||||
|
|
||||||
def claude_trigger_matches(
|
|
||||||
message_text: str, trigger_token: str, exact_match_only: bool
|
|
||||||
) -> bool:
|
|
||||||
body = str(message_text or "").strip()
|
|
||||||
parsed = parse_claude_command(body)
|
|
||||||
if parsed.command:
|
|
||||||
return True
|
|
||||||
trigger = str(trigger_token or "").strip()
|
|
||||||
if not trigger:
|
|
||||||
return False
|
|
||||||
if exact_match_only:
|
|
||||||
return body.lower() == trigger.lower()
|
|
||||||
return trigger.lower() in body.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCommandHandler(CommandHandler):
|
|
||||||
slug = "claude"
|
|
||||||
_provider_name = "claude_cli"
|
|
||||||
_approval_prefix = "claude_approval"
|
|
||||||
|
|
||||||
async def _load_trigger(self, message_id: str) -> Message | None:
|
|
||||||
return await sync_to_async(
|
|
||||||
lambda: Message.objects.select_related(
|
|
||||||
"user", "session", "session__identifier", "reply_to"
|
|
||||||
)
|
|
||||||
.filter(id=message_id)
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
|
|
||||||
def _effective_scope(self, trigger: Message) -> tuple[str, str]:
|
|
||||||
service = str(getattr(trigger, "source_service", "") or "").strip().lower()
|
|
||||||
channel = str(getattr(trigger, "source_chat_id", "") or "").strip()
|
|
||||||
identifier = getattr(getattr(trigger, "session", None), "identifier", None)
|
|
||||||
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
|
|
||||||
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
|
|
||||||
if (
|
|
||||||
service == "web"
|
|
||||||
and fallback_service
|
|
||||||
and fallback_identifier
|
|
||||||
and fallback_service != "web"
|
|
||||||
):
|
|
||||||
return fallback_service, fallback_identifier
|
|
||||||
return service or "web", channel
|
|
||||||
|
|
||||||
async def _mapped_sources(
|
|
||||||
self, user, service: str, channel: str
|
|
||||||
) -> list[ChatTaskSource]:
|
|
||||||
variants = channel_variants(service, channel)
|
|
||||||
if not variants:
|
|
||||||
return []
|
|
||||||
return await sync_to_async(list)(
|
|
||||||
ChatTaskSource.objects.filter(
|
|
||||||
user=user,
|
|
||||||
enabled=True,
|
|
||||||
service=service,
|
|
||||||
channel_identifier__in=variants,
|
|
||||||
).select_related("project", "epic")
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _linked_task_from_reply(
|
|
||||||
self, user, reply_to: Message | None
|
|
||||||
) -> DerivedTask | None:
|
|
||||||
if reply_to is None:
|
|
||||||
return None
|
|
||||||
by_origin = await sync_to_async(
|
|
||||||
lambda: DerivedTask.objects.filter(user=user, origin_message=reply_to)
|
|
||||||
.select_related("project", "epic")
|
|
||||||
.order_by("-created_at")
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
if by_origin is not None:
|
|
||||||
return by_origin
|
|
||||||
return await sync_to_async(
|
|
||||||
lambda: DerivedTask.objects.filter(
|
|
||||||
user=user, events__source_message=reply_to
|
|
||||||
)
|
|
||||||
.select_related("project", "epic")
|
|
||||||
.order_by("-created_at")
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
|
|
||||||
def _extract_project_token(self, body_text: str) -> tuple[str, str]:
|
|
||||||
text = str(body_text or "")
|
|
||||||
m = _PROJECT_TOKEN_RE.search(text)
|
|
||||||
if not m:
|
|
||||||
return "", text
|
|
||||||
token = str(m.group(1) or "").strip()
|
|
||||||
cleaned = _PROJECT_TOKEN_RE.sub("", text).strip()
|
|
||||||
return token, cleaned
|
|
||||||
|
|
||||||
def _extract_reference(self, body_text: str) -> str:
|
|
||||||
m = _REFERENCE_RE.search(str(body_text or ""))
|
|
||||||
if not m:
|
|
||||||
return ""
|
|
||||||
return str(m.group(1) or "").strip()
|
|
||||||
|
|
||||||
async def _resolve_task(
|
|
||||||
self, user, reference_code: str, reply_task: DerivedTask | None
|
|
||||||
) -> DerivedTask | None:
|
|
||||||
if reference_code:
|
|
||||||
return await sync_to_async(
|
|
||||||
lambda: DerivedTask.objects.filter(
|
|
||||||
user=user, reference_code=reference_code
|
|
||||||
)
|
|
||||||
.select_related("project", "epic")
|
|
||||||
.order_by("-created_at")
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
return reply_task
|
|
||||||
|
|
||||||
async def _resolve_project(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
user,
|
|
||||||
service: str,
|
|
||||||
channel: str,
|
|
||||||
task: DerivedTask | None,
|
|
||||||
reply_task: DerivedTask | None,
|
|
||||||
project_token: str,
|
|
||||||
) -> tuple[TaskProject | None, str]:
|
|
||||||
if task is not None:
|
|
||||||
return task.project, ""
|
|
||||||
if reply_task is not None:
|
|
||||||
return reply_task.project, ""
|
|
||||||
if project_token:
|
|
||||||
project = await sync_to_async(
|
|
||||||
lambda: TaskProject.objects.filter(
|
|
||||||
user=user, name__iexact=project_token
|
|
||||||
).first()
|
|
||||||
)()
|
|
||||||
if project is not None:
|
|
||||||
return project, ""
|
|
||||||
return None, f"project_not_found:{project_token}"
|
|
||||||
|
|
||||||
mapped = await self._mapped_sources(user, service, channel)
|
|
||||||
project_ids = sorted({str(row.project_id) for row in mapped if row.project_id})
|
|
||||||
if len(project_ids) == 1:
|
|
||||||
project = next(
|
|
||||||
(
|
|
||||||
row.project
|
|
||||||
for row in mapped
|
|
||||||
if str(row.project_id) == project_ids[0]
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
return project, ""
|
|
||||||
if len(project_ids) > 1:
|
|
||||||
return None, "project_required:[project:Name]"
|
|
||||||
return None, "project_unresolved"
|
|
||||||
|
|
||||||
async def _post_source_status(
|
|
||||||
self, trigger: Message, text: str, suffix: str
|
|
||||||
) -> None:
|
|
||||||
await post_status_in_source(
|
|
||||||
trigger_message=trigger,
|
|
||||||
text=text,
|
|
||||||
origin_tag=f"claude-status:{suffix}",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _run_status(
|
|
||||||
self, trigger: Message, service: str, channel: str, project: TaskProject | None
|
|
||||||
) -> CommandResult:
|
|
||||||
def _load_runs():
|
|
||||||
qs = CodexRun.objects.filter(user=trigger.user)
|
|
||||||
if service:
|
|
||||||
qs = qs.filter(source_service=service)
|
|
||||||
if channel:
|
|
||||||
qs = qs.filter(source_channel=channel)
|
|
||||||
if project is not None:
|
|
||||||
qs = qs.filter(project=project)
|
|
||||||
return list(qs.order_by("-created_at")[:10])
|
|
||||||
|
|
||||||
runs = await sync_to_async(_load_runs)()
|
|
||||||
if not runs:
|
|
||||||
await self._post_source_status(
|
|
||||||
trigger, "[claude] no recent runs for this scope.", "empty"
|
|
||||||
)
|
|
||||||
return CommandResult(ok=True, status="ok", payload={"count": 0})
|
|
||||||
lines = ["[claude] recent runs:"]
|
|
||||||
for row in runs:
|
|
||||||
ref = str(getattr(getattr(row, "task", None), "reference_code", "") or "-")
|
|
||||||
summary = str((row.result_payload or {}).get("summary") or "").strip()
|
|
||||||
summary_part = f" · {summary}" if summary else ""
|
|
||||||
lines.append(f"- {row.status} run={row.id} task=#{ref}{summary_part}")
|
|
||||||
await self._post_source_status(trigger, "\n".join(lines), "runs")
|
|
||||||
return CommandResult(ok=True, status="ok", payload={"count": len(runs)})
|
|
||||||
|
|
||||||
async def _run_approval_action(
|
|
||||||
self,
|
|
||||||
trigger: Message,
|
|
||||||
parsed: ClaudeParsedCommand,
|
|
||||||
current_service: str,
|
|
||||||
current_channel: str,
|
|
||||||
) -> CommandResult:
|
|
||||||
cfg = await sync_to_async(
|
|
||||||
lambda: TaskProviderConfig.objects.filter(
|
|
||||||
user=trigger.user, provider=self._provider_name
|
|
||||||
).first()
|
|
||||||
)()
|
|
||||||
settings_payload = dict(getattr(cfg, "settings", {}) or {})
|
|
||||||
approver_service = (
|
|
||||||
str(settings_payload.get("approver_service") or "").strip().lower()
|
|
||||||
)
|
|
||||||
approver_identifier = str(
|
|
||||||
settings_payload.get("approver_identifier") or ""
|
|
||||||
).strip()
|
|
||||||
if not approver_service or not approver_identifier:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error="approver_channel_not_configured"
|
|
||||||
)
|
|
||||||
|
|
||||||
if str(current_service or "").strip().lower() != approver_service or str(
|
|
||||||
current_channel or ""
|
|
||||||
).strip() not in set(channel_variants(approver_service, approver_identifier)):
|
|
||||||
return CommandResult(
|
|
||||||
ok=False,
|
|
||||||
status="failed",
|
|
||||||
error="approval_command_not_allowed_in_this_channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
approval_key = parsed.approval_key
|
|
||||||
request = await sync_to_async(
|
|
||||||
lambda: CodexPermissionRequest.objects.select_related(
|
|
||||||
"codex_run", "external_sync_event"
|
|
||||||
)
|
|
||||||
.filter(user=trigger.user, approval_key=approval_key)
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
if request is None:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error="approval_key_not_found"
|
|
||||||
)
|
|
||||||
|
|
||||||
now = timezone.now()
|
|
||||||
if parsed.command == "approve":
|
|
||||||
request.status = "approved"
|
|
||||||
request.resolved_at = now
|
|
||||||
request.resolved_by_identifier = current_channel
|
|
||||||
request.resolution_note = "approved via claude command"
|
|
||||||
await sync_to_async(request.save)(
|
|
||||||
update_fields=[
|
|
||||||
"status",
|
|
||||||
"resolved_at",
|
|
||||||
"resolved_by_identifier",
|
|
||||||
"resolution_note",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if request.external_sync_event_id:
|
|
||||||
await sync_to_async(
|
|
||||||
ExternalSyncEvent.objects.filter(
|
|
||||||
id=request.external_sync_event_id
|
|
||||||
).update
|
|
||||||
)(
|
|
||||||
status="ok",
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
run = request.codex_run
|
|
||||||
run.status = "approved_waiting_resume"
|
|
||||||
run.error = ""
|
|
||||||
await sync_to_async(run.save)(
|
|
||||||
update_fields=["status", "error", "updated_at"]
|
|
||||||
)
|
|
||||||
source_service = str(run.source_service or "")
|
|
||||||
source_channel = str(run.source_channel or "")
|
|
||||||
resume_payload = dict(request.resume_payload or {})
|
|
||||||
resume_action = str(resume_payload.get("action") or "").strip().lower()
|
|
||||||
resume_provider_payload = dict(resume_payload.get("provider_payload") or {})
|
|
||||||
if resume_action and resume_provider_payload:
|
|
||||||
provider_payload = dict(resume_provider_payload)
|
|
||||||
provider_payload["codex_run_id"] = str(run.id)
|
|
||||||
provider_payload["source_service"] = source_service
|
|
||||||
provider_payload["source_channel"] = source_channel
|
|
||||||
event_action = resume_action
|
|
||||||
resume_idempotency_key = str(
|
|
||||||
resume_payload.get("idempotency_key") or ""
|
|
||||||
).strip()
|
|
||||||
resume_event_key = (
|
|
||||||
resume_idempotency_key
|
|
||||||
if resume_idempotency_key
|
|
||||||
else f"{self._approval_prefix}:{approval_key}:approved"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
provider_payload = dict(
|
|
||||||
run.request_payload.get("provider_payload") or {}
|
|
||||||
)
|
|
||||||
provider_payload.update(
|
|
||||||
{
|
|
||||||
"mode": "approval_response",
|
|
||||||
"approval_key": approval_key,
|
|
||||||
"resume_payload": dict(request.resume_payload or {}),
|
|
||||||
"codex_run_id": str(run.id),
|
|
||||||
"source_service": source_service,
|
|
||||||
"source_channel": source_channel,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
event_action = "append_update"
|
|
||||||
resume_event_key = f"{self._approval_prefix}:{approval_key}:approved"
|
|
||||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
|
||||||
idempotency_key=resume_event_key,
|
|
||||||
defaults={
|
|
||||||
"user": trigger.user,
|
|
||||||
"task_id": run.task_id,
|
|
||||||
"task_event_id": run.derived_task_event_id,
|
|
||||||
"provider": self._provider_name,
|
|
||||||
"status": "pending",
|
|
||||||
"payload": {
|
|
||||||
"action": event_action,
|
|
||||||
"provider_payload": provider_payload,
|
|
||||||
},
|
|
||||||
"error": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return CommandResult(
|
|
||||||
ok=True,
|
|
||||||
status="ok",
|
|
||||||
payload={"approval_key": approval_key, "resolution": "approved"},
|
|
||||||
)
|
|
||||||
|
|
||||||
request.status = "denied"
|
|
||||||
request.resolved_at = now
|
|
||||||
request.resolved_by_identifier = current_channel
|
|
||||||
request.resolution_note = "denied via claude command"
|
|
||||||
await sync_to_async(request.save)(
|
|
||||||
update_fields=[
|
|
||||||
"status",
|
|
||||||
"resolved_at",
|
|
||||||
"resolved_by_identifier",
|
|
||||||
"resolution_note",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if request.external_sync_event_id:
|
|
||||||
await sync_to_async(
|
|
||||||
ExternalSyncEvent.objects.filter(
|
|
||||||
id=request.external_sync_event_id
|
|
||||||
).update
|
|
||||||
)(
|
|
||||||
status="failed",
|
|
||||||
error="approval_denied",
|
|
||||||
)
|
|
||||||
run = request.codex_run
|
|
||||||
run.status = "denied"
|
|
||||||
run.error = "approval_denied"
|
|
||||||
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
|
||||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
|
||||||
idempotency_key=f"{self._approval_prefix}:{approval_key}:denied",
|
|
||||||
defaults={
|
|
||||||
"user": trigger.user,
|
|
||||||
"task_id": run.task_id,
|
|
||||||
"task_event_id": run.derived_task_event_id,
|
|
||||||
"provider": self._provider_name,
|
|
||||||
"status": "failed",
|
|
||||||
"payload": {
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": {
|
|
||||||
"mode": "approval_response",
|
|
||||||
"approval_key": approval_key,
|
|
||||||
"codex_run_id": str(run.id),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"error": "approval_denied",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return CommandResult(
|
|
||||||
ok=True,
|
|
||||||
status="ok",
|
|
||||||
payload={"approval_key": approval_key, "resolution": "denied"},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _create_submission(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
trigger: Message,
|
|
||||||
mode: str,
|
|
||||||
body_text: str,
|
|
||||||
task: DerivedTask,
|
|
||||||
project: TaskProject,
|
|
||||||
) -> CommandResult:
|
|
||||||
cfg = await sync_to_async(
|
|
||||||
lambda: TaskProviderConfig.objects.filter(
|
|
||||||
user=trigger.user, provider=self._provider_name, enabled=True
|
|
||||||
).first()
|
|
||||||
)()
|
|
||||||
if cfg is None:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error="provider_disabled_or_missing"
|
|
||||||
)
|
|
||||||
|
|
||||||
service, channel = self._effective_scope(trigger)
|
|
||||||
external_chat_id = await sync_to_async(resolve_external_chat_id)(
|
|
||||||
user=trigger.user,
|
|
||||||
provider=self._provider_name,
|
|
||||||
service=service,
|
|
||||||
channel=channel,
|
|
||||||
)
|
|
||||||
payload = {
|
|
||||||
"task_id": str(task.id),
|
|
||||||
"reference_code": str(task.reference_code or ""),
|
|
||||||
"title": str(task.title or ""),
|
|
||||||
"external_key": str(task.external_key or ""),
|
|
||||||
"project_name": str(getattr(project, "name", "") or ""),
|
|
||||||
"epic_name": str(getattr(getattr(task, "epic", None), "name", "") or ""),
|
|
||||||
"source_service": service,
|
|
||||||
"source_channel": channel,
|
|
||||||
"external_chat_id": external_chat_id,
|
|
||||||
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
|
|
||||||
"trigger_message_id": str(trigger.id),
|
|
||||||
"mode": mode,
|
|
||||||
"command_text": str(body_text or ""),
|
|
||||||
}
|
|
||||||
if mode == "plan":
|
|
||||||
anchor = trigger.reply_to
|
|
||||||
if anchor is None:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error="reply_required_for_claude_plan"
|
|
||||||
)
|
|
||||||
rows = await sync_to_async(list)(
|
|
||||||
Message.objects.filter(
|
|
||||||
user=trigger.user,
|
|
||||||
session=trigger.session,
|
|
||||||
ts__gte=int(anchor.ts or 0),
|
|
||||||
ts__lte=int(trigger.ts or 0),
|
|
||||||
)
|
|
||||||
.order_by("ts")
|
|
||||||
.select_related(
|
|
||||||
"session", "session__identifier", "session__identifier__person"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
payload["reply_context"] = {
|
|
||||||
"anchor_message_id": str(anchor.id),
|
|
||||||
"trigger_message_id": str(trigger.id),
|
|
||||||
"message_ids": [str(row.id) for row in rows],
|
|
||||||
"content": plain_text_blob(rows),
|
|
||||||
}
|
|
||||||
|
|
||||||
run = await sync_to_async(CodexRun.objects.create)(
|
|
||||||
user=trigger.user,
|
|
||||||
task=task,
|
|
||||||
source_message=trigger,
|
|
||||||
project=project,
|
|
||||||
epic=getattr(task, "epic", None),
|
|
||||||
source_service=service,
|
|
||||||
source_channel=channel,
|
|
||||||
external_chat_id=external_chat_id,
|
|
||||||
status="waiting_approval",
|
|
||||||
request_payload={
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": dict(payload),
|
|
||||||
},
|
|
||||||
result_payload={},
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
payload["codex_run_id"] = str(run.id)
|
|
||||||
run.request_payload = {
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": dict(payload),
|
|
||||||
}
|
|
||||||
await sync_to_async(run.save)(update_fields=["request_payload", "updated_at"])
|
|
||||||
|
|
||||||
idempotency_key = f"claude_cmd:{trigger.id}:{mode}:{task.id}:{hashlib.sha1(str(body_text or '').encode('utf-8')).hexdigest()[:12]}"
|
|
||||||
await sync_to_async(queue_codex_event_with_pre_approval)(
|
|
||||||
user=trigger.user,
|
|
||||||
run=run,
|
|
||||||
task=task,
|
|
||||||
task_event=None,
|
|
||||||
action="append_update",
|
|
||||||
provider_payload=dict(payload),
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
return CommandResult(
|
|
||||||
ok=True,
|
|
||||||
status="ok",
|
|
||||||
payload={"codex_run_id": str(run.id), "approval_required": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute(self, ctx: CommandContext) -> CommandResult:
|
|
||||||
trigger = await self._load_trigger(ctx.message_id)
|
|
||||||
if trigger is None:
|
|
||||||
return CommandResult(ok=False, status="failed", error="trigger_not_found")
|
|
||||||
|
|
||||||
profile = await sync_to_async(
|
|
||||||
lambda: CommandProfile.objects.filter(
|
|
||||||
user=trigger.user, slug=self.slug, enabled=True
|
|
||||||
).first()
|
|
||||||
)()
|
|
||||||
if profile is None:
|
|
||||||
return CommandResult(ok=False, status="skipped", error="profile_missing")
|
|
||||||
|
|
||||||
parsed = parse_claude_command(ctx.message_text)
|
|
||||||
if not parsed.command:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="skipped", error="claude_command_not_matched"
|
|
||||||
)
|
|
||||||
|
|
||||||
service, channel = self._effective_scope(trigger)
|
|
||||||
|
|
||||||
if parsed.command == "status":
|
|
||||||
project = None
|
|
||||||
reply_task = await self._linked_task_from_reply(
|
|
||||||
trigger.user, trigger.reply_to
|
|
||||||
)
|
|
||||||
if reply_task is not None:
|
|
||||||
project = reply_task.project
|
|
||||||
return await self._run_status(trigger, service, channel, project)
|
|
||||||
|
|
||||||
if parsed.command in {"approve", "deny"}:
|
|
||||||
return await self._run_approval_action(
|
|
||||||
trigger,
|
|
||||||
parsed,
|
|
||||||
current_service=str(ctx.service or ""),
|
|
||||||
current_channel=str(ctx.channel_identifier or ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
project_token, cleaned_body = self._extract_project_token(parsed.body_text)
|
|
||||||
reference_code = self._extract_reference(cleaned_body)
|
|
||||||
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to)
|
|
||||||
task = await self._resolve_task(trigger.user, reference_code, reply_task)
|
|
||||||
if task is None:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error="task_target_required"
|
|
||||||
)
|
|
||||||
|
|
||||||
project, project_error = await self._resolve_project(
|
|
||||||
user=trigger.user,
|
|
||||||
service=service,
|
|
||||||
channel=channel,
|
|
||||||
task=task,
|
|
||||||
reply_task=reply_task,
|
|
||||||
project_token=project_token,
|
|
||||||
)
|
|
||||||
if project is None:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error=project_error or "project_unresolved"
|
|
||||||
)
|
|
||||||
|
|
||||||
mode = "plan" if parsed.command == "plan" else "default"
|
|
||||||
return await self._create_submission(
|
|
||||||
trigger=trigger,
|
|
||||||
mode=mode,
|
|
||||||
body_text=cleaned_body,
|
|
||||||
task=task,
|
|
||||||
project=project,
|
|
||||||
)
|
|
||||||
@@ -1,627 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from core.commands.base import CommandContext, CommandHandler, CommandResult
|
|
||||||
from core.commands.delivery import post_status_in_source
|
|
||||||
from core.messaging.text_export import plain_text_blob
|
|
||||||
from core.models import (
|
|
||||||
ChatTaskSource,
|
|
||||||
CodexPermissionRequest,
|
|
||||||
CodexRun,
|
|
||||||
CommandProfile,
|
|
||||||
DerivedTask,
|
|
||||||
ExternalSyncEvent,
|
|
||||||
Message,
|
|
||||||
TaskProject,
|
|
||||||
TaskProviderConfig,
|
|
||||||
)
|
|
||||||
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
|
|
||||||
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
|
|
||||||
|
|
||||||
_CODEX_DEFAULT_RE = re.compile(
|
|
||||||
r"^\s*(?:\.codex\b|#codex#?)(?P<body>.*)$",
|
|
||||||
re.IGNORECASE | re.DOTALL,
|
|
||||||
)
|
|
||||||
_CODEX_PLAN_RE = re.compile(
|
|
||||||
r"^\s*(?:\.codex\s+plan\b|#codex\s+plan#?)(?P<body>.*)$",
|
|
||||||
re.IGNORECASE | re.DOTALL,
|
|
||||||
)
|
|
||||||
_CODEX_STATUS_RE = re.compile(
|
|
||||||
r"^\s*(?:\.codex\s+status\b|#codex\s+status#?)\s*$", re.IGNORECASE
|
|
||||||
)
|
|
||||||
_CODEX_APPROVE_DENY_RE = re.compile(
|
|
||||||
r"^\s*(?:\.codex|#codex)\s+(?P<action>approve|deny)\s+(?P<approval_key>[A-Za-z0-9._:-]+)#?\s*$",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_PROJECT_TOKEN_RE = re.compile(r"\[\s*project\s*:\s*([^\]]+)\]", re.IGNORECASE)
|
|
||||||
_REFERENCE_RE = re.compile(r"(?<!\w)#([A-Za-z0-9_-]+)\b")
|
|
||||||
|
|
||||||
|
|
||||||
class CodexParsedCommand(dict):
|
|
||||||
@property
|
|
||||||
def command(self) -> str | None:
|
|
||||||
value = self.get("command")
|
|
||||||
return str(value) if value else None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def body_text(self) -> str:
|
|
||||||
return str(self.get("body_text") or "")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def approval_key(self) -> str:
|
|
||||||
return str(self.get("approval_key") or "")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_codex_command(text: str) -> CodexParsedCommand:
|
|
||||||
body = str(text or "")
|
|
||||||
m = _CODEX_APPROVE_DENY_RE.match(body)
|
|
||||||
if m:
|
|
||||||
return CodexParsedCommand(
|
|
||||||
command=str(m.group("action") or "").strip().lower(),
|
|
||||||
body_text="",
|
|
||||||
approval_key=str(m.group("approval_key") or "").strip(),
|
|
||||||
)
|
|
||||||
if _CODEX_STATUS_RE.match(body):
|
|
||||||
return CodexParsedCommand(command="status", body_text="", approval_key="")
|
|
||||||
m = _CODEX_PLAN_RE.match(body)
|
|
||||||
if m:
|
|
||||||
return CodexParsedCommand(
|
|
||||||
command="plan",
|
|
||||||
body_text=str(m.group("body") or "").strip(),
|
|
||||||
approval_key="",
|
|
||||||
)
|
|
||||||
m = _CODEX_DEFAULT_RE.match(body)
|
|
||||||
if m:
|
|
||||||
return CodexParsedCommand(
|
|
||||||
command="default",
|
|
||||||
body_text=str(m.group("body") or "").strip(),
|
|
||||||
approval_key="",
|
|
||||||
)
|
|
||||||
return CodexParsedCommand(command=None, body_text="", approval_key="")
|
|
||||||
|
|
||||||
|
|
||||||
def codex_trigger_matches(
|
|
||||||
message_text: str, trigger_token: str, exact_match_only: bool
|
|
||||||
) -> bool:
|
|
||||||
body = str(message_text or "").strip()
|
|
||||||
parsed = parse_codex_command(body)
|
|
||||||
if parsed.command:
|
|
||||||
return True
|
|
||||||
trigger = str(trigger_token or "").strip()
|
|
||||||
if not trigger:
|
|
||||||
return False
|
|
||||||
if exact_match_only:
|
|
||||||
return body.lower() == trigger.lower()
|
|
||||||
return trigger.lower() in body.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class CodexCommandHandler(CommandHandler):
|
|
||||||
slug = "codex"
|
|
||||||
|
|
||||||
async def _load_trigger(self, message_id: str) -> Message | None:
|
|
||||||
return await sync_to_async(
|
|
||||||
lambda: Message.objects.select_related(
|
|
||||||
"user", "session", "session__identifier", "reply_to"
|
|
||||||
)
|
|
||||||
.filter(id=message_id)
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
|
|
||||||
def _effective_scope(self, trigger: Message) -> tuple[str, str]:
|
|
||||||
service = str(getattr(trigger, "source_service", "") or "").strip().lower()
|
|
||||||
channel = str(getattr(trigger, "source_chat_id", "") or "").strip()
|
|
||||||
identifier = getattr(getattr(trigger, "session", None), "identifier", None)
|
|
||||||
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
|
|
||||||
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
|
|
||||||
if (
|
|
||||||
service == "web"
|
|
||||||
and fallback_service
|
|
||||||
and fallback_identifier
|
|
||||||
and fallback_service != "web"
|
|
||||||
):
|
|
||||||
return fallback_service, fallback_identifier
|
|
||||||
return service or "web", channel
|
|
||||||
|
|
||||||
async def _mapped_sources(
|
|
||||||
self, user, service: str, channel: str
|
|
||||||
) -> list[ChatTaskSource]:
|
|
||||||
variants = channel_variants(service, channel)
|
|
||||||
if not variants:
|
|
||||||
return []
|
|
||||||
return await sync_to_async(list)(
|
|
||||||
ChatTaskSource.objects.filter(
|
|
||||||
user=user,
|
|
||||||
enabled=True,
|
|
||||||
service=service,
|
|
||||||
channel_identifier__in=variants,
|
|
||||||
).select_related("project", "epic")
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _linked_task_from_reply(
|
|
||||||
self, user, reply_to: Message | None
|
|
||||||
) -> DerivedTask | None:
|
|
||||||
if reply_to is None:
|
|
||||||
return None
|
|
||||||
by_origin = await sync_to_async(
|
|
||||||
lambda: DerivedTask.objects.filter(user=user, origin_message=reply_to)
|
|
||||||
.select_related("project", "epic")
|
|
||||||
.order_by("-created_at")
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
if by_origin is not None:
|
|
||||||
return by_origin
|
|
||||||
return await sync_to_async(
|
|
||||||
lambda: DerivedTask.objects.filter(
|
|
||||||
user=user, events__source_message=reply_to
|
|
||||||
)
|
|
||||||
.select_related("project", "epic")
|
|
||||||
.order_by("-created_at")
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
|
|
||||||
def _extract_project_token(self, body_text: str) -> tuple[str, str]:
|
|
||||||
text = str(body_text or "")
|
|
||||||
m = _PROJECT_TOKEN_RE.search(text)
|
|
||||||
if not m:
|
|
||||||
return "", text
|
|
||||||
token = str(m.group(1) or "").strip()
|
|
||||||
cleaned = _PROJECT_TOKEN_RE.sub("", text).strip()
|
|
||||||
return token, cleaned
|
|
||||||
|
|
||||||
def _extract_reference(self, body_text: str) -> str:
|
|
||||||
m = _REFERENCE_RE.search(str(body_text or ""))
|
|
||||||
if not m:
|
|
||||||
return ""
|
|
||||||
return str(m.group(1) or "").strip()
|
|
||||||
|
|
||||||
async def _resolve_task(
|
|
||||||
self, user, reference_code: str, reply_task: DerivedTask | None
|
|
||||||
) -> DerivedTask | None:
|
|
||||||
if reference_code:
|
|
||||||
return await sync_to_async(
|
|
||||||
lambda: DerivedTask.objects.filter(
|
|
||||||
user=user, reference_code=reference_code
|
|
||||||
)
|
|
||||||
.select_related("project", "epic")
|
|
||||||
.order_by("-created_at")
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
return reply_task
|
|
||||||
|
|
||||||
async def _resolve_project(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
user,
|
|
||||||
service: str,
|
|
||||||
channel: str,
|
|
||||||
task: DerivedTask | None,
|
|
||||||
reply_task: DerivedTask | None,
|
|
||||||
project_token: str,
|
|
||||||
) -> tuple[TaskProject | None, str]:
|
|
||||||
if task is not None:
|
|
||||||
return task.project, ""
|
|
||||||
if reply_task is not None:
|
|
||||||
return reply_task.project, ""
|
|
||||||
if project_token:
|
|
||||||
project = await sync_to_async(
|
|
||||||
lambda: TaskProject.objects.filter(
|
|
||||||
user=user, name__iexact=project_token
|
|
||||||
).first()
|
|
||||||
)()
|
|
||||||
if project is not None:
|
|
||||||
return project, ""
|
|
||||||
return None, f"project_not_found:{project_token}"
|
|
||||||
|
|
||||||
mapped = await self._mapped_sources(user, service, channel)
|
|
||||||
project_ids = sorted({str(row.project_id) for row in mapped if row.project_id})
|
|
||||||
if len(project_ids) == 1:
|
|
||||||
project = next(
|
|
||||||
(
|
|
||||||
row.project
|
|
||||||
for row in mapped
|
|
||||||
if str(row.project_id) == project_ids[0]
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
return project, ""
|
|
||||||
if len(project_ids) > 1:
|
|
||||||
return None, "project_required:[project:Name]"
|
|
||||||
return None, "project_unresolved"
|
|
||||||
|
|
||||||
async def _post_source_status(
|
|
||||||
self, trigger: Message, text: str, suffix: str
|
|
||||||
) -> None:
|
|
||||||
await post_status_in_source(
|
|
||||||
trigger_message=trigger,
|
|
||||||
text=text,
|
|
||||||
origin_tag=f"codex-status:{suffix}",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _run_status(
|
|
||||||
self, trigger: Message, service: str, channel: str, project: TaskProject | None
|
|
||||||
) -> CommandResult:
|
|
||||||
def _load_runs():
|
|
||||||
qs = CodexRun.objects.filter(user=trigger.user)
|
|
||||||
if service:
|
|
||||||
qs = qs.filter(source_service=service)
|
|
||||||
if channel:
|
|
||||||
qs = qs.filter(source_channel=channel)
|
|
||||||
if project is not None:
|
|
||||||
qs = qs.filter(project=project)
|
|
||||||
return list(qs.order_by("-created_at")[:10])
|
|
||||||
|
|
||||||
runs = await sync_to_async(_load_runs)()
|
|
||||||
if not runs:
|
|
||||||
await self._post_source_status(
|
|
||||||
trigger, "[codex] no recent runs for this scope.", "empty"
|
|
||||||
)
|
|
||||||
return CommandResult(ok=True, status="ok", payload={"count": 0})
|
|
||||||
lines = ["[codex] recent runs:"]
|
|
||||||
for row in runs:
|
|
||||||
ref = str(getattr(getattr(row, "task", None), "reference_code", "") or "-")
|
|
||||||
summary = str((row.result_payload or {}).get("summary") or "").strip()
|
|
||||||
summary_part = f" · {summary}" if summary else ""
|
|
||||||
lines.append(f"- {row.status} run={row.id} task=#{ref}{summary_part}")
|
|
||||||
await self._post_source_status(trigger, "\n".join(lines), "runs")
|
|
||||||
return CommandResult(ok=True, status="ok", payload={"count": len(runs)})
|
|
||||||
|
|
||||||
async def _run_approval_action(
|
|
||||||
self,
|
|
||||||
trigger: Message,
|
|
||||||
parsed: CodexParsedCommand,
|
|
||||||
current_service: str,
|
|
||||||
current_channel: str,
|
|
||||||
) -> CommandResult:
|
|
||||||
cfg = await sync_to_async(
|
|
||||||
lambda: TaskProviderConfig.objects.filter(
|
|
||||||
user=trigger.user, provider="codex_cli"
|
|
||||||
).first()
|
|
||||||
)()
|
|
||||||
settings_payload = dict(getattr(cfg, "settings", {}) or {})
|
|
||||||
approver_service = (
|
|
||||||
str(settings_payload.get("approver_service") or "").strip().lower()
|
|
||||||
)
|
|
||||||
approver_identifier = str(
|
|
||||||
settings_payload.get("approver_identifier") or ""
|
|
||||||
).strip()
|
|
||||||
if not approver_service or not approver_identifier:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error="approver_channel_not_configured"
|
|
||||||
)
|
|
||||||
|
|
||||||
if str(current_service or "").strip().lower() != approver_service or str(
|
|
||||||
current_channel or ""
|
|
||||||
).strip() not in set(channel_variants(approver_service, approver_identifier)):
|
|
||||||
return CommandResult(
|
|
||||||
ok=False,
|
|
||||||
status="failed",
|
|
||||||
error="approval_command_not_allowed_in_this_channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
approval_key = parsed.approval_key
|
|
||||||
request = await sync_to_async(
|
|
||||||
lambda: CodexPermissionRequest.objects.select_related(
|
|
||||||
"codex_run", "external_sync_event"
|
|
||||||
)
|
|
||||||
.filter(user=trigger.user, approval_key=approval_key)
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
if request is None:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error="approval_key_not_found"
|
|
||||||
)
|
|
||||||
|
|
||||||
now = timezone.now()
|
|
||||||
if parsed.command == "approve":
|
|
||||||
request.status = "approved"
|
|
||||||
request.resolved_at = now
|
|
||||||
request.resolved_by_identifier = current_channel
|
|
||||||
request.resolution_note = "approved via command"
|
|
||||||
await sync_to_async(request.save)(
|
|
||||||
update_fields=[
|
|
||||||
"status",
|
|
||||||
"resolved_at",
|
|
||||||
"resolved_by_identifier",
|
|
||||||
"resolution_note",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if request.external_sync_event_id:
|
|
||||||
await sync_to_async(
|
|
||||||
ExternalSyncEvent.objects.filter(
|
|
||||||
id=request.external_sync_event_id
|
|
||||||
).update
|
|
||||||
)(
|
|
||||||
status="ok",
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
run = request.codex_run
|
|
||||||
run.status = "approved_waiting_resume"
|
|
||||||
run.error = ""
|
|
||||||
await sync_to_async(run.save)(
|
|
||||||
update_fields=["status", "error", "updated_at"]
|
|
||||||
)
|
|
||||||
source_service = str(run.source_service or "")
|
|
||||||
source_channel = str(run.source_channel or "")
|
|
||||||
resume_payload = dict(request.resume_payload or {})
|
|
||||||
resume_action = str(resume_payload.get("action") or "").strip().lower()
|
|
||||||
resume_provider_payload = dict(resume_payload.get("provider_payload") or {})
|
|
||||||
if resume_action and resume_provider_payload:
|
|
||||||
provider_payload = dict(resume_provider_payload)
|
|
||||||
provider_payload["codex_run_id"] = str(run.id)
|
|
||||||
provider_payload["source_service"] = source_service
|
|
||||||
provider_payload["source_channel"] = source_channel
|
|
||||||
event_action = resume_action
|
|
||||||
resume_idempotency_key = str(
|
|
||||||
resume_payload.get("idempotency_key") or ""
|
|
||||||
).strip()
|
|
||||||
resume_event_key = (
|
|
||||||
resume_idempotency_key
|
|
||||||
if resume_idempotency_key
|
|
||||||
else f"codex_approval:{approval_key}:approved"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
provider_payload = dict(
|
|
||||||
run.request_payload.get("provider_payload") or {}
|
|
||||||
)
|
|
||||||
provider_payload.update(
|
|
||||||
{
|
|
||||||
"mode": "approval_response",
|
|
||||||
"approval_key": approval_key,
|
|
||||||
"resume_payload": dict(request.resume_payload or {}),
|
|
||||||
"codex_run_id": str(run.id),
|
|
||||||
"source_service": source_service,
|
|
||||||
"source_channel": source_channel,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
event_action = "append_update"
|
|
||||||
resume_event_key = f"codex_approval:{approval_key}:approved"
|
|
||||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
|
||||||
idempotency_key=resume_event_key,
|
|
||||||
defaults={
|
|
||||||
"user": trigger.user,
|
|
||||||
"task_id": run.task_id,
|
|
||||||
"task_event_id": run.derived_task_event_id,
|
|
||||||
"provider": "codex_cli",
|
|
||||||
"status": "pending",
|
|
||||||
"payload": {
|
|
||||||
"action": event_action,
|
|
||||||
"provider_payload": provider_payload,
|
|
||||||
},
|
|
||||||
"error": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return CommandResult(
|
|
||||||
ok=True,
|
|
||||||
status="ok",
|
|
||||||
payload={"approval_key": approval_key, "resolution": "approved"},
|
|
||||||
)
|
|
||||||
|
|
||||||
request.status = "denied"
|
|
||||||
request.resolved_at = now
|
|
||||||
request.resolved_by_identifier = current_channel
|
|
||||||
request.resolution_note = "denied via command"
|
|
||||||
await sync_to_async(request.save)(
|
|
||||||
update_fields=[
|
|
||||||
"status",
|
|
||||||
"resolved_at",
|
|
||||||
"resolved_by_identifier",
|
|
||||||
"resolution_note",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if request.external_sync_event_id:
|
|
||||||
await sync_to_async(
|
|
||||||
ExternalSyncEvent.objects.filter(
|
|
||||||
id=request.external_sync_event_id
|
|
||||||
).update
|
|
||||||
)(
|
|
||||||
status="failed",
|
|
||||||
error="approval_denied",
|
|
||||||
)
|
|
||||||
run = request.codex_run
|
|
||||||
run.status = "denied"
|
|
||||||
run.error = "approval_denied"
|
|
||||||
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
|
||||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
|
||||||
idempotency_key=f"codex_approval:{approval_key}:denied",
|
|
||||||
defaults={
|
|
||||||
"user": trigger.user,
|
|
||||||
"task_id": run.task_id,
|
|
||||||
"task_event_id": run.derived_task_event_id,
|
|
||||||
"provider": "codex_cli",
|
|
||||||
"status": "failed",
|
|
||||||
"payload": {
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": {
|
|
||||||
"mode": "approval_response",
|
|
||||||
"approval_key": approval_key,
|
|
||||||
"codex_run_id": str(run.id),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"error": "approval_denied",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return CommandResult(
|
|
||||||
ok=True,
|
|
||||||
status="ok",
|
|
||||||
payload={"approval_key": approval_key, "resolution": "denied"},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _create_submission(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
trigger: Message,
|
|
||||||
mode: str,
|
|
||||||
body_text: str,
|
|
||||||
task: DerivedTask,
|
|
||||||
project: TaskProject,
|
|
||||||
) -> CommandResult:
|
|
||||||
cfg = await sync_to_async(
|
|
||||||
lambda: TaskProviderConfig.objects.filter(
|
|
||||||
user=trigger.user, provider="codex_cli", enabled=True
|
|
||||||
).first()
|
|
||||||
)()
|
|
||||||
if cfg is None:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error="provider_disabled_or_missing"
|
|
||||||
)
|
|
||||||
|
|
||||||
service, channel = self._effective_scope(trigger)
|
|
||||||
external_chat_id = await sync_to_async(resolve_external_chat_id)(
|
|
||||||
user=trigger.user,
|
|
||||||
provider="codex_cli",
|
|
||||||
service=service,
|
|
||||||
channel=channel,
|
|
||||||
)
|
|
||||||
payload = {
|
|
||||||
"task_id": str(task.id),
|
|
||||||
"reference_code": str(task.reference_code or ""),
|
|
||||||
"title": str(task.title or ""),
|
|
||||||
"external_key": str(task.external_key or ""),
|
|
||||||
"project_name": str(getattr(project, "name", "") or ""),
|
|
||||||
"epic_name": str(getattr(getattr(task, "epic", None), "name", "") or ""),
|
|
||||||
"source_service": service,
|
|
||||||
"source_channel": channel,
|
|
||||||
"external_chat_id": external_chat_id,
|
|
||||||
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
|
|
||||||
"trigger_message_id": str(trigger.id),
|
|
||||||
"mode": mode,
|
|
||||||
"command_text": str(body_text or ""),
|
|
||||||
}
|
|
||||||
if mode == "plan":
|
|
||||||
anchor = trigger.reply_to
|
|
||||||
if anchor is None:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error="reply_required_for_codex_plan"
|
|
||||||
)
|
|
||||||
rows = await sync_to_async(list)(
|
|
||||||
Message.objects.filter(
|
|
||||||
user=trigger.user,
|
|
||||||
session=trigger.session,
|
|
||||||
ts__gte=int(anchor.ts or 0),
|
|
||||||
ts__lte=int(trigger.ts or 0),
|
|
||||||
)
|
|
||||||
.order_by("ts")
|
|
||||||
.select_related(
|
|
||||||
"session", "session__identifier", "session__identifier__person"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
payload["reply_context"] = {
|
|
||||||
"anchor_message_id": str(anchor.id),
|
|
||||||
"trigger_message_id": str(trigger.id),
|
|
||||||
"message_ids": [str(row.id) for row in rows],
|
|
||||||
"content": plain_text_blob(rows),
|
|
||||||
}
|
|
||||||
|
|
||||||
run = await sync_to_async(CodexRun.objects.create)(
|
|
||||||
user=trigger.user,
|
|
||||||
task=task,
|
|
||||||
source_message=trigger,
|
|
||||||
project=project,
|
|
||||||
epic=getattr(task, "epic", None),
|
|
||||||
source_service=service,
|
|
||||||
source_channel=channel,
|
|
||||||
external_chat_id=external_chat_id,
|
|
||||||
status="waiting_approval",
|
|
||||||
request_payload={
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": dict(payload),
|
|
||||||
},
|
|
||||||
result_payload={},
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
payload["codex_run_id"] = str(run.id)
|
|
||||||
run.request_payload = {
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": dict(payload),
|
|
||||||
}
|
|
||||||
await sync_to_async(run.save)(update_fields=["request_payload", "updated_at"])
|
|
||||||
|
|
||||||
idempotency_key = f"codex_cmd:{trigger.id}:{mode}:{task.id}:{hashlib.sha1(str(body_text or '').encode('utf-8')).hexdigest()[:12]}"
|
|
||||||
await sync_to_async(queue_codex_event_with_pre_approval)(
|
|
||||||
user=trigger.user,
|
|
||||||
run=run,
|
|
||||||
task=task,
|
|
||||||
task_event=None,
|
|
||||||
action="append_update",
|
|
||||||
provider_payload=dict(payload),
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
return CommandResult(
|
|
||||||
ok=True,
|
|
||||||
status="ok",
|
|
||||||
payload={"codex_run_id": str(run.id), "approval_required": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute(self, ctx: CommandContext) -> CommandResult:
|
|
||||||
trigger = await self._load_trigger(ctx.message_id)
|
|
||||||
if trigger is None:
|
|
||||||
return CommandResult(ok=False, status="failed", error="trigger_not_found")
|
|
||||||
|
|
||||||
profile = await sync_to_async(
|
|
||||||
lambda: CommandProfile.objects.filter(
|
|
||||||
user=trigger.user, slug=self.slug, enabled=True
|
|
||||||
).first()
|
|
||||||
)()
|
|
||||||
if profile is None:
|
|
||||||
return CommandResult(ok=False, status="skipped", error="profile_missing")
|
|
||||||
|
|
||||||
parsed = parse_codex_command(ctx.message_text)
|
|
||||||
if not parsed.command:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="skipped", error="codex_command_not_matched"
|
|
||||||
)
|
|
||||||
|
|
||||||
service, channel = self._effective_scope(trigger)
|
|
||||||
|
|
||||||
if parsed.command == "status":
|
|
||||||
project = None
|
|
||||||
reply_task = await self._linked_task_from_reply(
|
|
||||||
trigger.user, trigger.reply_to
|
|
||||||
)
|
|
||||||
if reply_task is not None:
|
|
||||||
project = reply_task.project
|
|
||||||
return await self._run_status(trigger, service, channel, project)
|
|
||||||
|
|
||||||
if parsed.command in {"approve", "deny"}:
|
|
||||||
return await self._run_approval_action(
|
|
||||||
trigger,
|
|
||||||
parsed,
|
|
||||||
current_service=str(ctx.service or ""),
|
|
||||||
current_channel=str(ctx.channel_identifier or ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
project_token, cleaned_body = self._extract_project_token(parsed.body_text)
|
|
||||||
reference_code = self._extract_reference(cleaned_body)
|
|
||||||
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to)
|
|
||||||
task = await self._resolve_task(trigger.user, reference_code, reply_task)
|
|
||||||
if task is None:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error="task_target_required"
|
|
||||||
)
|
|
||||||
|
|
||||||
project, project_error = await self._resolve_project(
|
|
||||||
user=trigger.user,
|
|
||||||
service=service,
|
|
||||||
channel=channel,
|
|
||||||
task=task,
|
|
||||||
reply_task=reply_task,
|
|
||||||
project_token=project_token,
|
|
||||||
)
|
|
||||||
if project is None:
|
|
||||||
return CommandResult(
|
|
||||||
ok=False, status="failed", error=project_error or "project_unresolved"
|
|
||||||
)
|
|
||||||
|
|
||||||
mode = "plan" if parsed.command == "plan" else "default"
|
|
||||||
return await self._create_submission(
|
|
||||||
trigger=trigger,
|
|
||||||
mode=mode,
|
|
||||||
body_text=cleaned_body,
|
|
||||||
task=task,
|
|
||||||
project=project,
|
|
||||||
)
|
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
from django.urls import reverse
|
from core.settings_navigation import build_settings_navigation
|
||||||
|
|
||||||
|
|
||||||
def _tab(label: str, href: str, active: bool) -> dict:
|
|
||||||
return {
|
|
||||||
"label": label,
|
|
||||||
"href": href,
|
|
||||||
"active": bool(active),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def settings_hierarchy_nav(request):
|
def settings_hierarchy_nav(request):
|
||||||
@@ -15,150 +7,7 @@ def settings_hierarchy_nav(request):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
url_name = str(getattr(match, "url_name", "") or "")
|
url_name = str(getattr(match, "url_name", "") or "")
|
||||||
namespace = str(getattr(match, "namespace", "") or "")
|
settings_nav = build_settings_navigation(url_name)
|
||||||
path = str(getattr(request, "path", "") or "")
|
|
||||||
|
|
||||||
notifications_href = reverse("notifications_settings")
|
|
||||||
system_href = reverse("system_settings")
|
|
||||||
accessibility_href = reverse("accessibility_settings")
|
|
||||||
encryption_href = reverse("encryption_settings")
|
|
||||||
permissions_href = reverse("permission_settings")
|
|
||||||
security_2fa_href = reverse("security_2fa")
|
|
||||||
ai_models_href = reverse("ai_models")
|
|
||||||
ai_traces_href = reverse("ai_execution_log")
|
|
||||||
commands_href = reverse("command_routing")
|
|
||||||
business_plans_href = reverse("business_plan_inbox")
|
|
||||||
tasks_href = reverse("tasks_settings")
|
|
||||||
translation_href = reverse("translation_settings")
|
|
||||||
behavioral_href = reverse("behavioral_signals_settings")
|
|
||||||
|
|
||||||
categories = {
|
|
||||||
"general": {
|
|
||||||
"routes": {
|
|
||||||
"notifications_settings",
|
|
||||||
"notifications_update",
|
|
||||||
"system_settings",
|
|
||||||
"accessibility_settings",
|
|
||||||
},
|
|
||||||
"title": "General",
|
|
||||||
"tabs": [
|
|
||||||
(
|
|
||||||
"Notifications",
|
|
||||||
notifications_href,
|
|
||||||
lambda: path == notifications_href,
|
|
||||||
),
|
|
||||||
("System", system_href, lambda: path == system_href),
|
|
||||||
(
|
|
||||||
"Accessibility",
|
|
||||||
accessibility_href,
|
|
||||||
lambda: path == accessibility_href,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"routes": {
|
|
||||||
"security_settings",
|
|
||||||
"encryption_settings",
|
|
||||||
"permission_settings",
|
|
||||||
"security_2fa",
|
|
||||||
},
|
|
||||||
"title": "Security",
|
|
||||||
"tabs": [
|
|
||||||
("Encryption", encryption_href, lambda: path == encryption_href),
|
|
||||||
("Permissions", permissions_href, lambda: path == permissions_href),
|
|
||||||
(
|
|
||||||
"2FA",
|
|
||||||
security_2fa_href,
|
|
||||||
lambda: path == security_2fa_href or namespace == "two_factor",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"ai": {
|
|
||||||
"routes": {
|
|
||||||
"ai_settings",
|
|
||||||
"ai_models",
|
|
||||||
"ais",
|
|
||||||
"ai_create",
|
|
||||||
"ai_update",
|
|
||||||
"ai_delete",
|
|
||||||
"ai_execution_log",
|
|
||||||
},
|
|
||||||
"title": "AI",
|
|
||||||
"tabs": [
|
|
||||||
("Models", ai_models_href, lambda: path == ai_models_href),
|
|
||||||
("Traces", ai_traces_href, lambda: path == ai_traces_href),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"modules": {
|
|
||||||
"routes": {
|
|
||||||
"modules_settings",
|
|
||||||
"command_routing",
|
|
||||||
"business_plan_inbox",
|
|
||||||
"business_plan_editor",
|
|
||||||
"tasks_settings",
|
|
||||||
"translation_settings",
|
|
||||||
"translation_preview",
|
|
||||||
"availability_settings",
|
|
||||||
"behavioral_signals_settings",
|
|
||||||
"codex_settings",
|
|
||||||
"codex_approval",
|
|
||||||
},
|
|
||||||
"title": "Modules",
|
|
||||||
"tabs": [
|
|
||||||
("Commands", commands_href, lambda: path == commands_href),
|
|
||||||
(
|
|
||||||
"Business Plans",
|
|
||||||
business_plans_href,
|
|
||||||
lambda: url_name in {"business_plan_inbox", "business_plan_editor"},
|
|
||||||
),
|
|
||||||
("Task Automation", tasks_href, lambda: path == tasks_href),
|
|
||||||
(
|
|
||||||
"Translation",
|
|
||||||
translation_href,
|
|
||||||
lambda: url_name in {"translation_settings", "translation_preview"},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Behavioral Signals",
|
|
||||||
behavioral_href,
|
|
||||||
lambda: url_name
|
|
||||||
in {"availability_settings", "behavioral_signals_settings"},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
two_factor_security_routes = {
|
|
||||||
"profile",
|
|
||||||
"setup",
|
|
||||||
"backup_tokens",
|
|
||||||
"disable",
|
|
||||||
"phone_create",
|
|
||||||
"phone_delete",
|
|
||||||
}
|
|
||||||
|
|
||||||
if url_name in categories["general"]["routes"]:
|
|
||||||
category = categories["general"]
|
|
||||||
elif url_name in categories["security"]["routes"] or (
|
|
||||||
namespace == "two_factor" and url_name in two_factor_security_routes
|
|
||||||
):
|
|
||||||
category = categories["security"]
|
|
||||||
elif url_name in categories["ai"]["routes"]:
|
|
||||||
category = categories["ai"]
|
|
||||||
elif url_name in categories["modules"]["routes"]:
|
|
||||||
category = categories["modules"]
|
|
||||||
else:
|
|
||||||
category = None
|
|
||||||
|
|
||||||
if category is None:
|
|
||||||
settings_nav = None
|
|
||||||
else:
|
|
||||||
settings_nav = {
|
|
||||||
"title": str(category.get("title") or "Settings"),
|
|
||||||
"tabs": [
|
|
||||||
_tab(label, href, bool(is_active()))
|
|
||||||
for label, href, is_active in category.get("tabs", [])
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if not settings_nav:
|
if not settings_nav:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -10,22 +10,12 @@ from core.gateway.commands import (
|
|||||||
dispatch_gateway_command,
|
dispatch_gateway_command,
|
||||||
)
|
)
|
||||||
from core.models import (
|
from core.models import (
|
||||||
CodexPermissionRequest,
|
|
||||||
CodexRun,
|
|
||||||
DerivedTask,
|
DerivedTask,
|
||||||
ExternalSyncEvent,
|
|
||||||
Person,
|
Person,
|
||||||
TaskProject,
|
TaskProject,
|
||||||
User,
|
|
||||||
)
|
)
|
||||||
from core.tasks.engine import create_task_record_and_sync, mark_task_completed_and_sync
|
from core.tasks.engine import create_task_record_and_sync, mark_task_completed_and_sync
|
||||||
|
|
||||||
APPROVAL_PROVIDER_COMMANDS = {
|
|
||||||
".claude": "claude",
|
|
||||||
".codex": "codex_cli",
|
|
||||||
}
|
|
||||||
APPROVAL_EVENT_PREFIX = "codex_approval"
|
|
||||||
ACTION_TO_STATUS = {"approve": "approved", "reject": "denied"}
|
|
||||||
TASK_COMMAND_MATCH_RE = re.compile(r"^\s*(?:\.tasks\b|\.l\b|\.list\b)", re.IGNORECASE)
|
TASK_COMMAND_MATCH_RE = re.compile(r"^\s*(?:\.tasks\b|\.l\b|\.list\b)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,11 +25,6 @@ def gateway_help_lines() -> list[str]:
|
|||||||
" .contacts — list contacts",
|
" .contacts — list contacts",
|
||||||
" .whoami — show current user",
|
" .whoami — show current user",
|
||||||
" .help — show this help",
|
" .help — show this help",
|
||||||
"Approval commands:",
|
|
||||||
" .approval list-pending [all] — list pending approval requests",
|
|
||||||
" .approval approve <key> — approve a request",
|
|
||||||
" .approval reject <key> — reject a request",
|
|
||||||
" .approval status <key> — check request status",
|
|
||||||
"Task commands:",
|
"Task commands:",
|
||||||
" .l — shortcut for open task list",
|
" .l — shortcut for open task list",
|
||||||
" .tasks list [status] [limit] — list tasks",
|
" .tasks list [status] [limit] — list tasks",
|
||||||
@@ -50,138 +35,6 @@ def gateway_help_lines() -> list[str]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _resolve_request_provider(request):
|
|
||||||
event = getattr(request, "external_sync_event", None)
|
|
||||||
if event is None:
|
|
||||||
return ""
|
|
||||||
return str(getattr(event, "provider", "") or "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_approval_decision(request, decision):
|
|
||||||
status = ACTION_TO_STATUS.get(decision, decision)
|
|
||||||
request.status = status
|
|
||||||
await sync_to_async(request.save)(update_fields=["status"])
|
|
||||||
run = None
|
|
||||||
if request.codex_run_id:
|
|
||||||
run = await sync_to_async(CodexRun.objects.get)(pk=request.codex_run_id)
|
|
||||||
run.status = "approved_waiting_resume" if status == "approved" else status
|
|
||||||
await sync_to_async(run.save)(update_fields=["status"])
|
|
||||||
if request.external_sync_event_id:
|
|
||||||
evt = await sync_to_async(ExternalSyncEvent.objects.get)(
|
|
||||||
pk=request.external_sync_event_id
|
|
||||||
)
|
|
||||||
evt.status = "ok"
|
|
||||||
await sync_to_async(evt.save)(update_fields=["status"])
|
|
||||||
user = await sync_to_async(User.objects.get)(pk=request.user_id)
|
|
||||||
task = None
|
|
||||||
if run is not None and run.task_id:
|
|
||||||
task = await sync_to_async(DerivedTask.objects.get)(pk=run.task_id)
|
|
||||||
ikey = f"{APPROVAL_EVENT_PREFIX}:{request.approval_key}:{status}"
|
|
||||||
await sync_to_async(ExternalSyncEvent.objects.get_or_create)(
|
|
||||||
idempotency_key=ikey,
|
|
||||||
defaults={
|
|
||||||
"user": user,
|
|
||||||
"task": task,
|
|
||||||
"provider": "codex_cli",
|
|
||||||
"status": "pending",
|
|
||||||
"payload": {},
|
|
||||||
"error": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _approval_list_pending(user, scope, emit):
|
|
||||||
_ = scope
|
|
||||||
requests = await sync_to_async(list)(
|
|
||||||
CodexPermissionRequest.objects.filter(user=user, status="pending").order_by(
|
|
||||||
"-requested_at"
|
|
||||||
)[:20]
|
|
||||||
)
|
|
||||||
emit(f"pending={len(requests)}")
|
|
||||||
for req in requests:
|
|
||||||
emit(f" {req.approval_key}: {req.summary}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _approval_status(user, approval_key, emit):
|
|
||||||
try:
|
|
||||||
req = await sync_to_async(CodexPermissionRequest.objects.get)(
|
|
||||||
user=user, approval_key=approval_key
|
|
||||||
)
|
|
||||||
emit(f"status={req.status} key={req.approval_key}")
|
|
||||||
except CodexPermissionRequest.DoesNotExist:
|
|
||||||
emit(f"approval_key_not_found:{approval_key}")
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_approval_command(user, body, emit):
|
|
||||||
command = str(body or "").strip()
|
|
||||||
for prefix, expected_provider in APPROVAL_PROVIDER_COMMANDS.items():
|
|
||||||
if command.startswith(prefix + " ") or command == prefix:
|
|
||||||
sub = command[len(prefix) :].strip()
|
|
||||||
parts = sub.split()
|
|
||||||
if len(parts) >= 2 and parts[0] in ("approve", "reject"):
|
|
||||||
action, approval_key = parts[0], parts[1]
|
|
||||||
try:
|
|
||||||
req = await sync_to_async(
|
|
||||||
CodexPermissionRequest.objects.select_related(
|
|
||||||
"external_sync_event"
|
|
||||||
).get
|
|
||||||
)(user=user, approval_key=approval_key)
|
|
||||||
except CodexPermissionRequest.DoesNotExist:
|
|
||||||
emit(f"approval_key_not_found:{approval_key}")
|
|
||||||
return True
|
|
||||||
provider = _resolve_request_provider(req)
|
|
||||||
if not provider.startswith(expected_provider):
|
|
||||||
emit(
|
|
||||||
f"approval_key_not_for_provider:{approval_key} provider={provider}"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
await _apply_approval_decision(req, action)
|
|
||||||
emit(f"{action}d: {approval_key}")
|
|
||||||
return True
|
|
||||||
emit(f"usage: {prefix} approve|reject <key>")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if not command.startswith(".approval"):
|
|
||||||
return False
|
|
||||||
|
|
||||||
rest = command[len(".approval") :].strip()
|
|
||||||
|
|
||||||
if rest.split() and rest.split()[0] in ("approve", "reject"):
|
|
||||||
parts = rest.split()
|
|
||||||
action = parts[0]
|
|
||||||
approval_key = parts[1] if len(parts) > 1 else ""
|
|
||||||
if not approval_key:
|
|
||||||
emit("usage: .approval approve|reject <key>")
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
req = await sync_to_async(
|
|
||||||
CodexPermissionRequest.objects.select_related("external_sync_event").get
|
|
||||||
)(user=user, approval_key=approval_key)
|
|
||||||
except CodexPermissionRequest.DoesNotExist:
|
|
||||||
emit(f"approval_key_not_found:{approval_key}")
|
|
||||||
return True
|
|
||||||
await _apply_approval_decision(req, action)
|
|
||||||
emit(f"{action}d: {approval_key}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if rest.startswith("list-pending"):
|
|
||||||
scope = rest[len("list-pending") :].strip() or "mine"
|
|
||||||
await _approval_list_pending(user, scope, emit)
|
|
||||||
return True
|
|
||||||
|
|
||||||
if rest.startswith("status "):
|
|
||||||
approval_key = rest[len("status ") :].strip()
|
|
||||||
await _approval_status(user, approval_key, emit)
|
|
||||||
return True
|
|
||||||
|
|
||||||
emit(
|
|
||||||
"approval: .approval approve|reject <key> | "
|
|
||||||
".approval list-pending [all] | "
|
|
||||||
".approval status <key>"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_task_create(rest: str) -> tuple[str, str]:
|
def _parse_task_create(rest: str) -> tuple[str, str]:
|
||||||
text = str(rest or "").strip()
|
text = str(rest or "").strip()
|
||||||
if not text.lower().startswith("add "):
|
if not text.lower().startswith("add "):
|
||||||
@@ -347,9 +200,6 @@ async def dispatch_builtin_gateway_command(
|
|||||||
out(str(user.__dict__))
|
out(str(user.__dict__))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _approval_handler(_ctx, out):
|
|
||||||
return await handle_approval_command(user, text, out)
|
|
||||||
|
|
||||||
async def _tasks_handler(_ctx, out):
|
async def _tasks_handler(_ctx, out):
|
||||||
return await handle_tasks_command(
|
return await handle_tasks_command(
|
||||||
user,
|
user,
|
||||||
@@ -379,17 +229,6 @@ async def dispatch_builtin_gateway_command(
|
|||||||
matcher=lambda value: str(value or "").strip().lower() == ".whoami",
|
matcher=lambda value: str(value or "").strip().lower() == ".whoami",
|
||||||
handler=_whoami_handler,
|
handler=_whoami_handler,
|
||||||
),
|
),
|
||||||
GatewayCommandRoute(
|
|
||||||
name="approval",
|
|
||||||
scope_key="gateway.approval",
|
|
||||||
matcher=lambda value: str(value or "").strip().lower().startswith(".approval")
|
|
||||||
or any(
|
|
||||||
str(value or "").strip().lower().startswith(prefix + " ")
|
|
||||||
or str(value or "").strip().lower() == prefix
|
|
||||||
for prefix in APPROVAL_PROVIDER_COMMANDS
|
|
||||||
),
|
|
||||||
handler=_approval_handler,
|
|
||||||
),
|
|
||||||
GatewayCommandRoute(
|
GatewayCommandRoute(
|
||||||
name="tasks",
|
name="tasks",
|
||||||
scope_key="gateway.tasks",
|
scope_key="gateway.tasks",
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from core.clients.transport import send_message_raw
|
|
||||||
from core.models import (
|
|
||||||
CodexPermissionRequest,
|
|
||||||
CodexRun,
|
|
||||||
ExternalSyncEvent,
|
|
||||||
TaskProviderConfig,
|
|
||||||
)
|
|
||||||
from core.tasks.providers import get_provider
|
|
||||||
from core.util import logs
|
|
||||||
|
|
||||||
log = logs.get_logger("codex_worker")
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = (
|
|
||||||
"Process queued external sync events for worker-backed providers (codex_cli)."
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument("--once", action="store_true", default=False)
|
|
||||||
parser.add_argument("--sleep-seconds", type=float, default=2.0)
|
|
||||||
parser.add_argument("--batch-size", type=int, default=20)
|
|
||||||
parser.add_argument("--provider", default="codex_cli")
|
|
||||||
|
|
||||||
def _claim_batch(self, provider: str, batch_size: int) -> list[str]:
|
|
||||||
ids: list[str] = []
|
|
||||||
rows = list(
|
|
||||||
ExternalSyncEvent.objects.filter(
|
|
||||||
provider=provider,
|
|
||||||
status__in=["pending", "retrying"],
|
|
||||||
)
|
|
||||||
.order_by("updated_at")[: max(1, batch_size)]
|
|
||||||
.values_list("id", flat=True)
|
|
||||||
)
|
|
||||||
for row_id in rows:
|
|
||||||
updated = ExternalSyncEvent.objects.filter(
|
|
||||||
id=row_id,
|
|
||||||
provider=provider,
|
|
||||||
status__in=["pending", "retrying"],
|
|
||||||
).update(status="retrying")
|
|
||||||
if updated:
|
|
||||||
ids.append(str(row_id))
|
|
||||||
return ids
|
|
||||||
|
|
||||||
def _run_event(self, event: ExternalSyncEvent) -> None:
|
|
||||||
provider = get_provider(event.provider)
|
|
||||||
if not bool(getattr(provider, "run_in_worker", False)):
|
|
||||||
return
|
|
||||||
|
|
||||||
cfg = (
|
|
||||||
TaskProviderConfig.objects.filter(
|
|
||||||
user=event.user,
|
|
||||||
provider=event.provider,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
.order_by("-updated_at")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if cfg is None:
|
|
||||||
event.status = "failed"
|
|
||||||
event.error = "provider_disabled_or_missing"
|
|
||||||
event.save(update_fields=["status", "error", "updated_at"])
|
|
||||||
provider_payload = dict((event.payload or {}).get("provider_payload") or {})
|
|
||||||
run_id = str(provider_payload.get("codex_run_id") or "").strip()
|
|
||||||
if run_id:
|
|
||||||
CodexRun.objects.filter(id=run_id, user=event.user).update(
|
|
||||||
status="failed",
|
|
||||||
error="provider_disabled_or_missing",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = dict(event.payload or {})
|
|
||||||
action = str(payload.get("action") or "append_update").strip().lower()
|
|
||||||
provider_payload = dict(payload.get("provider_payload") or payload)
|
|
||||||
run_id = str(
|
|
||||||
provider_payload.get("codex_run_id") or payload.get("codex_run_id") or ""
|
|
||||||
).strip()
|
|
||||||
codex_run = None
|
|
||||||
if run_id:
|
|
||||||
codex_run = CodexRun.objects.filter(id=run_id, user=event.user).first()
|
|
||||||
if codex_run is None and event.task_id:
|
|
||||||
codex_run = (
|
|
||||||
CodexRun.objects.filter(
|
|
||||||
user=event.user,
|
|
||||||
task_id=event.task_id,
|
|
||||||
status__in=["queued", "running", "approved_waiting_resume"],
|
|
||||||
)
|
|
||||||
.order_by("-updated_at")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if codex_run is not None:
|
|
||||||
codex_run.status = "running"
|
|
||||||
codex_run.error = ""
|
|
||||||
codex_run.save(update_fields=["status", "error", "updated_at"])
|
|
||||||
|
|
||||||
if action == "create":
|
|
||||||
result = provider.create_task(dict(cfg.settings or {}), provider_payload)
|
|
||||||
elif action == "complete":
|
|
||||||
result = provider.mark_complete(dict(cfg.settings or {}), provider_payload)
|
|
||||||
elif action == "link_task":
|
|
||||||
result = provider.link_task(dict(cfg.settings or {}), provider_payload)
|
|
||||||
else:
|
|
||||||
result = provider.append_update(dict(cfg.settings or {}), provider_payload)
|
|
||||||
|
|
||||||
result_payload = dict(result.payload or {})
|
|
||||||
requires_approval = bool(result_payload.get("requires_approval"))
|
|
||||||
if requires_approval:
|
|
||||||
approval_key = str(
|
|
||||||
result_payload.get("approval_key") or uuid.uuid4().hex[:12]
|
|
||||||
).strip()
|
|
||||||
permission_request = dict(result_payload.get("permission_request") or {})
|
|
||||||
summary = str(
|
|
||||||
result_payload.get("summary") or permission_request.get("summary") or ""
|
|
||||||
).strip()
|
|
||||||
requested_permissions = permission_request.get("requested_permissions")
|
|
||||||
if not isinstance(requested_permissions, (list, dict)):
|
|
||||||
requested_permissions = permission_request or {}
|
|
||||||
resume_payload = result_payload.get("resume_payload")
|
|
||||||
if not isinstance(resume_payload, dict):
|
|
||||||
resume_payload = {}
|
|
||||||
event.status = "waiting_approval"
|
|
||||||
event.error = ""
|
|
||||||
event.payload = dict(payload, worker_processed=True, result=result_payload)
|
|
||||||
event.save(update_fields=["status", "error", "payload", "updated_at"])
|
|
||||||
if codex_run is not None:
|
|
||||||
codex_run.status = "waiting_approval"
|
|
||||||
codex_run.result_payload = dict(result_payload)
|
|
||||||
codex_run.error = ""
|
|
||||||
codex_run.save(
|
|
||||||
update_fields=["status", "result_payload", "error", "updated_at"]
|
|
||||||
)
|
|
||||||
CodexPermissionRequest.objects.update_or_create(
|
|
||||||
approval_key=approval_key,
|
|
||||||
defaults={
|
|
||||||
"user": event.user,
|
|
||||||
"codex_run": (
|
|
||||||
codex_run
|
|
||||||
if codex_run is not None
|
|
||||||
else CodexRun.objects.create(
|
|
||||||
user=event.user,
|
|
||||||
task=event.task,
|
|
||||||
derived_task_event=event.task_event,
|
|
||||||
source_service=str(
|
|
||||||
provider_payload.get("source_service") or ""
|
|
||||||
),
|
|
||||||
source_channel=str(
|
|
||||||
provider_payload.get("source_channel") or ""
|
|
||||||
),
|
|
||||||
external_chat_id=str(
|
|
||||||
provider_payload.get("external_chat_id") or ""
|
|
||||||
),
|
|
||||||
status="waiting_approval",
|
|
||||||
request_payload=dict(payload or {}),
|
|
||||||
result_payload=dict(result_payload),
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"external_sync_event": event,
|
|
||||||
"summary": summary,
|
|
||||||
"requested_permissions": (
|
|
||||||
requested_permissions
|
|
||||||
if isinstance(requested_permissions, dict)
|
|
||||||
else {"items": list(requested_permissions or [])}
|
|
||||||
),
|
|
||||||
"resume_payload": dict(resume_payload or {}),
|
|
||||||
"status": "pending",
|
|
||||||
"resolved_at": None,
|
|
||||||
"resolved_by_identifier": "",
|
|
||||||
"resolution_note": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
approver_service = (
|
|
||||||
str((cfg.settings or {}).get("approver_service") or "").strip().lower()
|
|
||||||
)
|
|
||||||
approver_identifier = str(
|
|
||||||
(cfg.settings or {}).get("approver_identifier") or ""
|
|
||||||
).strip()
|
|
||||||
requested_text = (
|
|
||||||
result_payload.get("permission_request")
|
|
||||||
or result_payload.get("requested_permissions")
|
|
||||||
or {}
|
|
||||||
)
|
|
||||||
if approver_service and approver_identifier:
|
|
||||||
try:
|
|
||||||
async_to_sync(send_message_raw)(
|
|
||||||
approver_service,
|
|
||||||
approver_identifier,
|
|
||||||
text=(
|
|
||||||
f"[codex approval] key={approval_key}\\n"
|
|
||||||
f"summary={summary or 'Codex run requires approval'}\\n"
|
|
||||||
f"requested={requested_text}\\n"
|
|
||||||
f"use: .codex approve {approval_key} or .codex deny {approval_key}"
|
|
||||||
),
|
|
||||||
attachments=[],
|
|
||||||
metadata={"origin_tag": f"codex-approval:{approval_key}"},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
log.exception(
|
|
||||||
"failed to notify approver channel for approval_key=%s",
|
|
||||||
approval_key,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
source_service = (
|
|
||||||
str(provider_payload.get("source_service") or "").strip().lower()
|
|
||||||
)
|
|
||||||
source_channel = str(
|
|
||||||
provider_payload.get("source_channel") or ""
|
|
||||||
).strip()
|
|
||||||
if source_service and source_channel:
|
|
||||||
try:
|
|
||||||
async_to_sync(send_message_raw)(
|
|
||||||
source_service,
|
|
||||||
source_channel,
|
|
||||||
text=(
|
|
||||||
"[codex approval] approval is pending but no approver channel is configured. "
|
|
||||||
"Set approver_service and approver_identifier in Codex settings."
|
|
||||||
),
|
|
||||||
attachments=[],
|
|
||||||
metadata={"origin_tag": "codex-approval-missing-target"},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
log.exception(
|
|
||||||
"failed to notify source channel for missing approver target"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
event.status = "ok" if result.ok else "failed"
|
|
||||||
event.error = str(result.error or "")
|
|
||||||
event.payload = dict(
|
|
||||||
payload,
|
|
||||||
worker_processed=True,
|
|
||||||
result=result_payload,
|
|
||||||
)
|
|
||||||
event.save(update_fields=["status", "error", "payload", "updated_at"])
|
|
||||||
|
|
||||||
mode = str(provider_payload.get("mode") or "").strip().lower()
|
|
||||||
approval_key = str(provider_payload.get("approval_key") or "").strip()
|
|
||||||
if mode == "approval_response" and approval_key:
|
|
||||||
req = (
|
|
||||||
CodexPermissionRequest.objects.select_related(
|
|
||||||
"external_sync_event", "codex_run"
|
|
||||||
)
|
|
||||||
.filter(user=event.user, approval_key=approval_key)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if req and req.external_sync_event_id:
|
|
||||||
if result.ok:
|
|
||||||
ExternalSyncEvent.objects.filter(
|
|
||||||
id=req.external_sync_event_id
|
|
||||||
).update(
|
|
||||||
status="ok",
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
elif str(event.error or "").strip() == "approval_denied":
|
|
||||||
ExternalSyncEvent.objects.filter(
|
|
||||||
id=req.external_sync_event_id
|
|
||||||
).update(
|
|
||||||
status="failed",
|
|
||||||
error="approval_denied",
|
|
||||||
)
|
|
||||||
if codex_run is not None:
|
|
||||||
codex_run.status = "ok" if result.ok else "failed"
|
|
||||||
codex_run.error = str(result.error or "")
|
|
||||||
codex_run.result_payload = result_payload
|
|
||||||
codex_run.save(
|
|
||||||
update_fields=["status", "error", "result_payload", "updated_at"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
result.ok
|
|
||||||
and result.external_key
|
|
||||||
and event.task_id
|
|
||||||
and not str(event.task.external_key or "").strip()
|
|
||||||
):
|
|
||||||
event.task.external_key = str(result.external_key)
|
|
||||||
event.task.save(update_fields=["external_key"])
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
once = bool(options.get("once"))
|
|
||||||
sleep_seconds = max(0.2, float(options.get("sleep_seconds") or 2.0))
|
|
||||||
batch_size = max(1, int(options.get("batch_size") or 20))
|
|
||||||
provider_name = str(options.get("provider") or "codex_cli").strip().lower()
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"codex_worker started provider=%s once=%s sleep=%s batch_size=%s",
|
|
||||||
provider_name,
|
|
||||||
once,
|
|
||||||
sleep_seconds,
|
|
||||||
batch_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
claimed_ids = self._claim_batch(provider_name, batch_size)
|
|
||||||
if not claimed_ids:
|
|
||||||
if once:
|
|
||||||
log.info("codex_worker exiting: no pending events")
|
|
||||||
return
|
|
||||||
time.sleep(sleep_seconds)
|
|
||||||
continue
|
|
||||||
|
|
||||||
for row_id in claimed_ids:
|
|
||||||
event = (
|
|
||||||
ExternalSyncEvent.objects.filter(id=row_id)
|
|
||||||
.select_related("task", "user")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if event is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
self._run_event(event)
|
|
||||||
except Exception as exc:
|
|
||||||
log.exception("codex_worker failed processing id=%s", row_id)
|
|
||||||
ExternalSyncEvent.objects.filter(id=row_id).update(
|
|
||||||
status="failed",
|
|
||||||
error=f"worker_exception:{exc}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if once:
|
|
||||||
log.info("codex_worker processed %s event(s)", len(claimed_ids))
|
|
||||||
return
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from core.management.commands.codex_worker import Command as LegacyCodexWorkerCommand
|
|
||||||
|
|
||||||
|
|
||||||
class Command(LegacyCodexWorkerCommand):
|
|
||||||
help = (
|
|
||||||
"Process queued task-sync events for worker-backed providers (Codex + Claude)."
|
|
||||||
)
|
|
||||||
@@ -2769,7 +2769,7 @@ class ExternalChatLink(models.Model):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="external_chat_links",
|
related_name="external_chat_links",
|
||||||
)
|
)
|
||||||
provider = models.CharField(max_length=64, default="codex_cli")
|
provider = models.CharField(max_length=64, default="mock")
|
||||||
person = models.ForeignKey(
|
person = models.ForeignKey(
|
||||||
Person,
|
Person,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from core.realtime.typing_state import get_person_typing_state
|
|||||||
from core.views.compose import (
|
from core.views.compose import (
|
||||||
COMPOSE_WS_TOKEN_SALT,
|
COMPOSE_WS_TOKEN_SALT,
|
||||||
ComposeHistorySync,
|
ComposeHistorySync,
|
||||||
|
_render_compose_message_rows,
|
||||||
_serialize_messages_with_artifacts,
|
_serialize_messages_with_artifacts,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ def _load_since(user_id, service, identifier, person_id, after_ts, limit):
|
|||||||
if not session_ids:
|
if not session_ids:
|
||||||
return {
|
return {
|
||||||
"messages": [],
|
"messages": [],
|
||||||
|
"messages_html": "",
|
||||||
"last_ts": int(after_ts or 0),
|
"last_ts": int(after_ts or 0),
|
||||||
"person_id": int(person.id) if person is not None else 0,
|
"person_id": int(person.id) if person is not None else 0,
|
||||||
}
|
}
|
||||||
@@ -140,13 +142,16 @@ def _load_since(user_id, service, identifier, person_id, after_ts, limit):
|
|||||||
if str(value or "").strip()
|
if str(value or "").strip()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
serialized_messages = _serialize_messages_with_artifacts(
|
||||||
"messages": _serialize_messages_with_artifacts(
|
|
||||||
rows,
|
rows,
|
||||||
counterpart_identifiers=counterpart_identifiers,
|
counterpart_identifiers=counterpart_identifiers,
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
seed_previous=seed_previous,
|
seed_previous=seed_previous,
|
||||||
),
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"messages": serialized_messages,
|
||||||
|
"messages_html": _render_compose_message_rows(serialized_messages),
|
||||||
"last_ts": int(newest or after_ts or 0),
|
"last_ts": int(newest or after_ts or 0),
|
||||||
"person_id": int(effective_person_id),
|
"person_id": int(effective_person_id),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,12 +42,6 @@ CAPABILITY_SCOPES: tuple[CapabilityScope, ...] = (
|
|||||||
description="Handles .tasks list/show/complete/undo over gateway channels.",
|
description="Handles .tasks list/show/complete/undo over gateway channels.",
|
||||||
group="tasks",
|
group="tasks",
|
||||||
),
|
),
|
||||||
CapabilityScope(
|
|
||||||
key="gateway.approval",
|
|
||||||
label="Gateway approval commands",
|
|
||||||
description="Handles .approval/.codex/.claude approve/deny over gateway channels.",
|
|
||||||
group="command",
|
|
||||||
),
|
|
||||||
CapabilityScope(
|
CapabilityScope(
|
||||||
key="tasks.submit",
|
key="tasks.submit",
|
||||||
label="Task submissions from chat",
|
label="Task submissions from chat",
|
||||||
@@ -69,20 +63,6 @@ CAPABILITY_SCOPES: tuple[CapabilityScope, ...] = (
|
|||||||
group="command",
|
group="command",
|
||||||
owner_path="/settings/command-routing/",
|
owner_path="/settings/command-routing/",
|
||||||
),
|
),
|
||||||
CapabilityScope(
|
|
||||||
key="command.codex",
|
|
||||||
label="Codex command",
|
|
||||||
description="Controls Codex command execution.",
|
|
||||||
group="agentic",
|
|
||||||
owner_path="/settings/command-routing/",
|
|
||||||
),
|
|
||||||
CapabilityScope(
|
|
||||||
key="command.claude",
|
|
||||||
label="Claude command",
|
|
||||||
description="Controls Claude command execution.",
|
|
||||||
group="agentic",
|
|
||||||
owner_path="/settings/command-routing/",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
SCOPE_BY_KEY = {row.key: row for row in CAPABILITY_SCOPES}
|
SCOPE_BY_KEY = {row.key: row for row in CAPABILITY_SCOPES}
|
||||||
@@ -91,7 +71,6 @@ GROUP_LABELS: dict[str, str] = {
|
|||||||
"gateway": "Gateway",
|
"gateway": "Gateway",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"command": "Commands",
|
"command": "Commands",
|
||||||
"agentic": "Agentic",
|
|
||||||
"other": "Other",
|
"other": "Other",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,4 +87,3 @@ def all_scope_keys(*, configurable_only: bool = False) -> list[str]:
|
|||||||
def scope_record(scope_key: str) -> CapabilityScope | None:
|
def scope_record(scope_key: str) -> CapabilityScope | None:
|
||||||
key = str(scope_key or "").strip().lower()
|
key = str(scope_key or "").strip().lower()
|
||||||
return SCOPE_BY_KEY.get(key)
|
return SCOPE_BY_KEY.get(key)
|
||||||
|
|
||||||
|
|||||||
224
core/settings_navigation.py
Normal file
224
core/settings_navigation.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS_NAVIGATION_TREE = (
|
||||||
|
{
|
||||||
|
"key": "general",
|
||||||
|
"label": "General",
|
||||||
|
"href_name": "notifications_settings",
|
||||||
|
"children": (
|
||||||
|
{
|
||||||
|
"key": "notifications",
|
||||||
|
"label": "Notifications",
|
||||||
|
"href_name": "notifications_settings",
|
||||||
|
"route_names": ("notifications_settings", "notifications_update"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "system",
|
||||||
|
"label": "System",
|
||||||
|
"href_name": "system_settings",
|
||||||
|
"route_names": ("system_settings",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "accessibility",
|
||||||
|
"label": "Accessibility",
|
||||||
|
"href_name": "accessibility_settings",
|
||||||
|
"route_names": ("accessibility_settings",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "security",
|
||||||
|
"label": "Security",
|
||||||
|
"href_name": "encryption_settings",
|
||||||
|
"children": (
|
||||||
|
{
|
||||||
|
"key": "encryption",
|
||||||
|
"label": "Encryption",
|
||||||
|
"href_name": "encryption_settings",
|
||||||
|
"route_names": ("security_settings", "encryption_settings"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "permissions",
|
||||||
|
"label": "Permissions",
|
||||||
|
"href_name": "permission_settings",
|
||||||
|
"route_names": ("permission_settings",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "security_2fa",
|
||||||
|
"label": "2FA",
|
||||||
|
"href_name": "security_2fa",
|
||||||
|
"route_names": (
|
||||||
|
"security_2fa",
|
||||||
|
"profile",
|
||||||
|
"setup",
|
||||||
|
"backup_tokens",
|
||||||
|
"disable",
|
||||||
|
"phone_create",
|
||||||
|
"phone_delete",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ai",
|
||||||
|
"label": "AI",
|
||||||
|
"href_name": "ai_models",
|
||||||
|
"children": (
|
||||||
|
{
|
||||||
|
"key": "ai_models",
|
||||||
|
"label": "Models",
|
||||||
|
"href_name": "ai_models",
|
||||||
|
"route_names": (
|
||||||
|
"ai_settings",
|
||||||
|
"ai_models",
|
||||||
|
"ais",
|
||||||
|
"ai_create",
|
||||||
|
"ai_update",
|
||||||
|
"ai_delete",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ai_traces",
|
||||||
|
"label": "Traces",
|
||||||
|
"href_name": "ai_execution_log",
|
||||||
|
"route_names": (
|
||||||
|
"ai_execution_log",
|
||||||
|
"ai_execution_run_detail",
|
||||||
|
"ai_execution_run_detail_tab",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "modules",
|
||||||
|
"label": "Modules",
|
||||||
|
"href_name": "command_routing",
|
||||||
|
"children": (
|
||||||
|
{
|
||||||
|
"key": "command_routing",
|
||||||
|
"label": "Commands",
|
||||||
|
"href_name": "command_routing",
|
||||||
|
"route_names": ("modules_settings", "command_routing"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "business_plans",
|
||||||
|
"label": "Business Plans",
|
||||||
|
"href_name": "business_plan_inbox",
|
||||||
|
"route_names": ("business_plan_inbox", "business_plan_editor"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "tasks",
|
||||||
|
"label": "Task Automation",
|
||||||
|
"href_name": "tasks_settings",
|
||||||
|
"route_names": ("tasks_settings",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "translation",
|
||||||
|
"label": "Translation",
|
||||||
|
"href_name": "translation_settings",
|
||||||
|
"route_names": ("translation_settings", "translation_preview"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "behavioral",
|
||||||
|
"label": "Behavioral Signals",
|
||||||
|
"href_name": "behavioral_signals_settings",
|
||||||
|
"route_names": (
|
||||||
|
"availability_settings",
|
||||||
|
"behavioral_signals_settings",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _route_names_for_node(node: dict) -> set[str]:
|
||||||
|
route_names = {
|
||||||
|
str(value).strip()
|
||||||
|
for value in tuple(node.get("route_names") or ())
|
||||||
|
if str(value).strip()
|
||||||
|
}
|
||||||
|
for child in tuple(node.get("children") or ()):
|
||||||
|
route_names.update(_route_names_for_node(child))
|
||||||
|
return route_names
|
||||||
|
|
||||||
|
|
||||||
|
def _tab(label: str, href_name: str, active: bool) -> dict:
|
||||||
|
return {
|
||||||
|
"label": str(label or "").strip(),
|
||||||
|
"href": reverse(href_name),
|
||||||
|
"active": bool(active),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_active_path(nodes, url_name: str) -> list[dict]:
|
||||||
|
for node in tuple(nodes or ()):
|
||||||
|
node_routes = _route_names_for_node(node)
|
||||||
|
if url_name in node_routes:
|
||||||
|
child_path = _find_active_path(node.get("children") or (), url_name)
|
||||||
|
return [node, *child_path]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_navigation(url_name: str) -> dict | None:
|
||||||
|
current_route = str(url_name or "").strip()
|
||||||
|
if not current_route:
|
||||||
|
return None
|
||||||
|
|
||||||
|
active_path = _find_active_path(SETTINGS_NAVIGATION_TREE, current_route)
|
||||||
|
if not active_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
active_group = active_path[0]
|
||||||
|
navigation = {
|
||||||
|
"title": str(active_group.get("label") or "Settings"),
|
||||||
|
"groups": [
|
||||||
|
_tab(
|
||||||
|
group.get("label") or "",
|
||||||
|
group.get("href_name") or "",
|
||||||
|
bool(group.get("key")) == bool(active_group.get("key"))
|
||||||
|
and str(group.get("key") or "") == str(active_group.get("key") or ""),
|
||||||
|
)
|
||||||
|
for group in SETTINGS_NAVIGATION_TREE
|
||||||
|
],
|
||||||
|
"tabs": [],
|
||||||
|
"rows": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes = tuple(active_group.get("children") or ())
|
||||||
|
depth = 1
|
||||||
|
while nodes:
|
||||||
|
active_key = ""
|
||||||
|
if depth < len(active_path):
|
||||||
|
active_key = str(active_path[depth].get("key") or "")
|
||||||
|
tabs = [
|
||||||
|
_tab(
|
||||||
|
node.get("label") or "",
|
||||||
|
node.get("href_name") or "",
|
||||||
|
str(node.get("key") or "") == active_key,
|
||||||
|
)
|
||||||
|
for node in nodes
|
||||||
|
]
|
||||||
|
navigation["rows"].append(
|
||||||
|
{
|
||||||
|
"depth": depth,
|
||||||
|
"tabs": tabs,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if depth == 1:
|
||||||
|
navigation["tabs"] = tabs
|
||||||
|
current = next(
|
||||||
|
(
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if str(node.get("key") or "") == active_key
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
nodes = tuple((current or {}).get("children") or ())
|
||||||
|
depth += 1
|
||||||
|
|
||||||
|
return navigation
|
||||||
1
core/static/css/bulma-calendar.min.css
vendored
1
core/static/css/bulma-calendar.min.css
vendored
File diff suppressed because one or more lines are too long
1
core/static/css/bulma-slider.min.css
vendored
1
core/static/css/bulma-slider.min.css
vendored
File diff suppressed because one or more lines are too long
1
core/static/css/bulma-switch.min.css
vendored
1
core/static/css/bulma-switch.min.css
vendored
File diff suppressed because one or more lines are too long
1
core/static/css/bulma-tagsinput.min.css
vendored
1
core/static/css/bulma-tagsinput.min.css
vendored
@@ -1 +0,0 @@
|
|||||||
@-webkit-keyframes spinAround{from{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes spinAround{from{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.tagsinput{height:auto!important}.tagsinput .control{margin-bottom:.1em!important;margin-top:.1em!important}.tagsinput input{border:none;margin-bottom:.1em!important;margin-top:.1em!important}.tagsinput .tag.is-active{background-color:#00d1b2;color:#fff}
|
|
||||||
1
core/static/css/bulma-tooltip.min.css
vendored
1
core/static/css/bulma-tooltip.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,7 +1,8 @@
|
|||||||
.compose-shell {
|
.compose-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-shell-head {
|
.compose-shell .compose-shell-head {
|
||||||
@@ -22,13 +23,37 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compose-shell .compose-context-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-shell .compose-context-primary {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-shell .compose-context-secondary {
|
||||||
|
flex: 0 0 11rem;
|
||||||
|
min-width: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.compose-shell .compose-contact-switch,
|
.compose-shell .compose-contact-switch,
|
||||||
.compose-shell .compose-platform-switch {
|
.compose-shell .compose-platform-switch {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-shell .compose-contact-switch .select,
|
||||||
|
.compose-shell .compose-platform-switch .select,
|
||||||
|
.compose-shell .compose-contact-switch select,
|
||||||
|
.compose-shell .compose-platform-switch select {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-status {
|
.compose-shell .compose-status {
|
||||||
min-height: 1.25rem;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-status .button {
|
.compose-shell .compose-status .button {
|
||||||
@@ -49,12 +74,32 @@
|
|||||||
color: var(--bulma-success, #257953);
|
color: var(--bulma-success, #257953);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gia-widget-control.gia-widget-control-no-scroll > .compose-shell {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-control.gia-widget-control-no-scroll > .compose-shell .compose-shell-head,
|
||||||
|
.gia-widget-control.gia-widget-control-no-scroll > .compose-shell .compose-status,
|
||||||
|
.gia-widget-control.gia-widget-control-no-scroll > .compose-shell .compose-form {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-control.gia-widget-control-no-scroll > .compose-shell .compose-thread {
|
||||||
|
flex: 1 1 80%;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
.compose-shell .compose-thread {
|
.compose-shell .compose-thread {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 24rem;
|
flex: 1 1 auto;
|
||||||
max-height: 68vh;
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--bulma-border, #dbdbdb);
|
border: 1px solid var(--bulma-border, #dbdbdb);
|
||||||
@@ -236,7 +281,7 @@
|
|||||||
.compose-shell .compose-form {
|
.compose-shell .compose-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-send-safety {
|
.compose-shell .compose-send-safety {
|
||||||
@@ -271,21 +316,8 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-composer-capsule {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--bulma-border, #dbdbdb);
|
|
||||||
border-radius: 0.875rem;
|
|
||||||
background: var(--bulma-scheme-main-bis, #f7f8fa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.compose-shell .compose-textarea {
|
.compose-shell .compose-textarea {
|
||||||
flex: 1 1 auto;
|
height: 100%;
|
||||||
min-height: 2.75rem;
|
|
||||||
max-height: 8rem;
|
|
||||||
resize: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-send-btn {
|
.compose-shell .compose-send-btn {
|
||||||
@@ -305,19 +337,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.compose-shell .compose-context-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-shell .compose-context-secondary {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.compose-shell .compose-thread {
|
.compose-shell .compose-thread {
|
||||||
max-height: 60vh;
|
min-height: 18rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-bubble {
|
.compose-shell .compose-bubble {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-composer-capsule {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compose-shell .compose-send-btn {
|
.compose-shell .compose-send-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,143 @@
|
|||||||
|
:root {
|
||||||
|
--gia-navbar-height: 3.25rem;
|
||||||
|
--gia-page-bg: #edf2f8;
|
||||||
|
--gia-surface-1: rgba(255, 255, 255, 0.86);
|
||||||
|
--gia-surface-2: #ffffff;
|
||||||
|
--gia-surface-3: #f5f7fb;
|
||||||
|
--gia-border: rgba(15, 23, 42, 0.12);
|
||||||
|
--gia-border-strong: rgba(15, 23, 42, 0.2);
|
||||||
|
--gia-text: #0f172a;
|
||||||
|
--gia-text-muted: #526277;
|
||||||
|
--gia-text-soft: #748399;
|
||||||
|
--gia-hover: rgba(29, 78, 216, 0.08);
|
||||||
|
--gia-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
--gia-brand-surface: rgba(255, 255, 255, 0.92);
|
||||||
|
--gia-brand-border: rgba(15, 23, 42, 0.08);
|
||||||
|
--gia-brand-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
--bulma-body-background-color: var(--gia-page-bg);
|
||||||
|
--bulma-body-color: var(--gia-text);
|
||||||
|
--bulma-background: var(--gia-page-bg);
|
||||||
|
--bulma-text: var(--gia-text);
|
||||||
|
--bulma-text-strong: var(--gia-text);
|
||||||
|
--bulma-text-weak: var(--gia-text-muted);
|
||||||
|
--bulma-border: var(--gia-border);
|
||||||
|
--bulma-scheme-main: var(--gia-surface-2);
|
||||||
|
--bulma-scheme-main-bis: var(--gia-surface-3);
|
||||||
|
--bulma-scheme-main-ter: #e9eef6;
|
||||||
|
--bulma-link: #1d4ed8;
|
||||||
|
--bulma-link-light: #e8f0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--gia-page-bg: #0b1220;
|
||||||
|
--gia-surface-1: rgba(15, 23, 42, 0.9);
|
||||||
|
--gia-surface-2: #111827;
|
||||||
|
--gia-surface-3: #182235;
|
||||||
|
--gia-border: rgba(148, 163, 184, 0.22);
|
||||||
|
--gia-border-strong: rgba(148, 163, 184, 0.34);
|
||||||
|
--gia-text: #e5edf8;
|
||||||
|
--gia-text-muted: #b8c5d7;
|
||||||
|
--gia-text-soft: #94a3b8;
|
||||||
|
--gia-hover: rgba(148, 163, 184, 0.12);
|
||||||
|
--gia-shadow: 0 12px 28px rgba(0, 0, 0, 0.34);
|
||||||
|
--gia-brand-surface: rgba(17, 24, 39, 0.94);
|
||||||
|
--gia-brand-border: rgba(148, 163, 184, 0.24);
|
||||||
|
--gia-brand-shadow: 0 12px 28px rgba(0, 0, 0, 0.34);
|
||||||
|
--bulma-body-background-color: var(--gia-page-bg);
|
||||||
|
--bulma-body-color: var(--gia-text);
|
||||||
|
--bulma-background: var(--gia-page-bg);
|
||||||
|
--bulma-text: var(--gia-text);
|
||||||
|
--bulma-text-strong: #f8fbff;
|
||||||
|
--bulma-text-weak: var(--gia-text-muted);
|
||||||
|
--bulma-border: var(--gia-border);
|
||||||
|
--bulma-scheme-main: var(--gia-surface-2);
|
||||||
|
--bulma-scheme-main-bis: var(--gia-surface-3);
|
||||||
|
--bulma-scheme-main-ter: #1d293d;
|
||||||
|
--bulma-link: #93c5fd;
|
||||||
|
--bulma-link-light: rgba(59, 130, 246, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
background-color: var(--gia-page-bg);
|
||||||
|
color: var(--gia-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
body .title,
|
||||||
|
body .subtitle,
|
||||||
|
body .label,
|
||||||
|
body .content,
|
||||||
|
body .table,
|
||||||
|
body .panel-heading,
|
||||||
|
body .menu-label,
|
||||||
|
body .modal-card-title,
|
||||||
|
body .navbar-item,
|
||||||
|
body .navbar-link {
|
||||||
|
color: var(--gia-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
body .help,
|
||||||
|
body .has-text-grey,
|
||||||
|
body .has-text-grey-dark,
|
||||||
|
body .has-text-grey-light {
|
||||||
|
color: var(--gia-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box,
|
||||||
|
.card,
|
||||||
|
.panel,
|
||||||
|
.dropdown-content,
|
||||||
|
.modal-card,
|
||||||
|
.modal-card-head,
|
||||||
|
.modal-card-body,
|
||||||
|
.modal-card-foot,
|
||||||
|
.tabs a,
|
||||||
|
.menu-list a,
|
||||||
|
.pagination-link,
|
||||||
|
.pagination-next,
|
||||||
|
.pagination-previous,
|
||||||
|
.button.is-light,
|
||||||
|
.button.is-white {
|
||||||
|
background-color: var(--gia-surface-2);
|
||||||
|
border-color: var(--gia-border);
|
||||||
|
color: var(--gia-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs.is-boxed a {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card-head,
|
||||||
|
.modal-card-foot,
|
||||||
|
.panel-heading {
|
||||||
|
background-color: var(--gia-surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.is-light .message-body,
|
||||||
|
.table-container,
|
||||||
|
.floating-window .panel {
|
||||||
|
background-color: var(--gia-surface-2) !important;
|
||||||
|
border-color: var(--gia-border) !important;
|
||||||
|
color: var(--gia-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.textarea,
|
||||||
|
.select select {
|
||||||
|
background-color: var(--gia-surface-2);
|
||||||
|
color: var(--gia-text);
|
||||||
|
border-color: var(--gia-border-strong);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder,
|
||||||
|
.textarea::placeholder {
|
||||||
|
color: var(--gia-text-soft);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.icon { border-bottom: 0 !important; }
|
.icon { border-bottom: 0 !important; }
|
||||||
.wrap {
|
.wrap {
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
@@ -34,6 +174,7 @@
|
|||||||
|
|
||||||
.table {
|
.table {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
|
color: var(--gia-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
@@ -47,7 +188,7 @@ a.panel-block {
|
|||||||
tr:hover,
|
tr:hover,
|
||||||
a.panel-block:hover {
|
a.panel-block:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: rgba(221, 224, 255, 0.3) !important;
|
background-color: var(--gia-hover) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.has-background-grey-lighter {
|
.has-background-grey-lighter {
|
||||||
@@ -55,7 +196,9 @@ a.panel-block:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
background-color: rgba(0, 0, 0, 0.03) !important;
|
background-color: var(--gia-surface-1) !important;
|
||||||
|
border-bottom: 1px solid var(--gia-border);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gia-brand-shell {
|
.gia-brand-shell {
|
||||||
@@ -69,26 +212,30 @@ a.panel-block:hover {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: rgba(255, 255, 255, 0.82);
|
background: var(--gia-brand-surface);
|
||||||
border: 1px solid rgba(21, 28, 39, 0.08);
|
border: 1px solid var(--gia-brand-border);
|
||||||
box-shadow: 0 10px 24px rgba(21, 28, 39, 0.08);
|
box-shadow: var(--gia-brand-shadow);
|
||||||
|
color: var(--gia-text);
|
||||||
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
box-shadow 0.15s ease,
|
||||||
|
background-color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gia-brand-logo img {
|
.gia-brand-logo img {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .gia-brand-logo {
|
|
||||||
background: rgba(255, 255, 255, 0.96);
|
|
||||||
border-color: rgba(255, 255, 255, 0.82);
|
|
||||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section > .container.gia-page-shell,
|
.section > .container.gia-page-shell,
|
||||||
.section > .container {
|
.section > .container {
|
||||||
max-width: 1340px;
|
max-width: 1340px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gia-standard-page-shell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.gia-page-header {
|
.gia-page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -110,17 +257,12 @@ a.panel-block:hover {
|
|||||||
.table thead th {
|
.table thead th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: rgba(248, 250, 252, 0.96) !important;
|
background: var(--gia-surface-3) !important;
|
||||||
color: #1b1f2a !important;
|
color: var(--gia-text) !important;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .table thead th {
|
|
||||||
background: rgba(44, 44, 44, 0.96) !important;
|
|
||||||
color: #f3f5f8 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td,
|
.table td,
|
||||||
.table th {
|
.table th {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
@@ -131,14 +273,7 @@ a.panel-block:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button.is-light {
|
.button.is-light {
|
||||||
border-color: rgba(27, 38, 59, 0.12);
|
border-color: var(--gia-border-strong);
|
||||||
}
|
|
||||||
|
|
||||||
.input,
|
|
||||||
.textarea,
|
|
||||||
.select select {
|
|
||||||
border-color: rgba(27, 38, 59, 0.18);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus,
|
.input:focus,
|
||||||
@@ -148,6 +283,155 @@ a.panel-block:hover {
|
|||||||
box-shadow: 0 0 0 0.125em rgba(27, 99, 214, 0.14);
|
box-shadow: 0 0 0 0.125em rgba(27, 99, 214, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.gia-has-workspace-root,
|
||||||
|
body.gia-has-workspace {
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.gia-has-workspace {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.gia-has-workspace > .navbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.gia-has-workspace > .section.gia-workspace-page {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section.gia-workspace-page {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-workspace-shell {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-workspace-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-workspace-grid-column {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-workspace-grid {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--gia-border);
|
||||||
|
background: var(--gia-surface-1);
|
||||||
|
box-shadow: var(--gia-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-snap-assistant {
|
||||||
|
flex: 0 0 19rem;
|
||||||
|
min-width: 19rem;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--gia-border);
|
||||||
|
background: var(--gia-surface-2);
|
||||||
|
box-shadow: var(--gia-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-snap-assistant.is-hidden,
|
||||||
|
.gia-taskbar.is-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-snap-assistant-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-snap-assistant-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-snap-assistant-options {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-snap-assistant-options .button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-taskbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--gia-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--gia-surface-1);
|
||||||
|
box-shadow: var(--gia-shadow);
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-taskbar ul {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-taskbar li {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-taskbar a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-taskbar li.is-active a {
|
||||||
|
background: var(--bulma-link-light);
|
||||||
|
color: var(--bulma-link);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-taskbar li.is-minimized a {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.gia-has-workspace {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.gia-has-workspace-root {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-stack-item-content,
|
.grid-stack-item-content,
|
||||||
.floating-window {
|
.floating-window {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
@@ -156,17 +440,84 @@ a.panel-block:hover {
|
|||||||
overflow-y: hidden !important;
|
overflow-y: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.gia-widget-panel {
|
||||||
|
height: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--gia-border);
|
||||||
|
background: var(--gia-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-heading-main {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-heading-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-actions {
|
||||||
|
margin: 0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-actions .button {
|
||||||
|
padding-left: 0.55rem;
|
||||||
|
padding-right: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.75rem;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column !important;
|
align-items: stretch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-control {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-control.gia-widget-control-no-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-block {
|
.gia-widget-control.gia-widget-control-no-scroll > * {
|
||||||
overflow-y: auto;
|
flex: 1 1 auto;
|
||||||
overflow-x: auto;
|
min-height: 0;
|
||||||
min-height: 90%;
|
}
|
||||||
display: block;
|
|
||||||
|
.grid-stack-item.is-gia-active .gia-widget-panel {
|
||||||
|
border-color: rgba(50, 115, 220, 0.45);
|
||||||
|
box-shadow: 0 0 0 2px rgba(50, 115, 220, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-window {
|
.floating-window {
|
||||||
@@ -178,7 +529,7 @@ a.panel-block:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.floating-window .panel {
|
.floating-window .panel {
|
||||||
background-color: rgba(250, 250, 250, 0.8) !important;
|
background-color: var(--gia-surface-2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.float-right {
|
.float-right {
|
||||||
@@ -195,10 +546,10 @@ a.panel-block:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.osint-table-shell {
|
.osint-table-shell {
|
||||||
border: 1px solid rgba(127, 127, 127, 0.2);
|
border: 1px solid var(--gia-border);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 0.9rem;
|
padding: 0.9rem;
|
||||||
background: rgba(255, 255, 255, 0.45);
|
background: var(--gia-surface-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.osint-table-toolbar {
|
.osint-table-toolbar {
|
||||||
@@ -210,16 +561,73 @@ a.panel-block:hover {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gia-badge,
|
||||||
|
.task-ui-badge {
|
||||||
|
border: 1px solid var(--gia-border) !important;
|
||||||
|
color: var(--gia-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-badge.is-light,
|
||||||
|
.task-ui-badge {
|
||||||
|
background: var(--gia-surface-3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-badge.is-white {
|
||||||
|
background: var(--gia-surface-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-badge.is-dark {
|
||||||
|
background: var(--gia-text) !important;
|
||||||
|
color: var(--gia-surface-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.task-ui-badge {
|
.task-ui-badge {
|
||||||
background: #f5f5f5 !important;
|
|
||||||
border: 1px solid #dbdbdb !important;
|
|
||||||
color: #1f1f1f !important;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
padding: 0.25em 0.75em;
|
padding: 0.25em 0.75em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gia-tag-ribbon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-tag-ribbon > .tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-tag-ribbon-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
padding-right: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .tag.is-white {
|
||||||
|
background: var(--gia-surface-2) !important;
|
||||||
|
color: var(--gia-text) !important;
|
||||||
|
border: 1px solid var(--gia-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .tag.is-dark {
|
||||||
|
background: var(--gia-surface-3) !important;
|
||||||
|
color: var(--gia-text) !important;
|
||||||
|
border: 1px solid var(--gia-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .tag.is-light:not(.is-primary):not(.is-link):not(.is-info):not(.is-success):not(.is-warning):not(.is-danger):not(.is-dark):not(.is-white):not(.is-black) {
|
||||||
|
background: var(--gia-surface-3) !important;
|
||||||
|
color: var(--gia-text) !important;
|
||||||
|
border: 1px solid var(--gia-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.osint-results-table th {
|
.osint-results-table th {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -242,8 +650,8 @@ a.panel-block:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navbar-dropdown .navbar-item.is-current-route {
|
.navbar-dropdown .navbar-item.is-current-route {
|
||||||
background-color: rgba(50, 115, 220, 0.14) !important;
|
background-color: var(--bulma-link-light) !important;
|
||||||
color: #1f4f99 !important;
|
color: var(--bulma-link) !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,13 +663,21 @@ a.panel-block:hover {
|
|||||||
|
|
||||||
.brand-theme-toggle {
|
.brand-theme-toggle {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0.45rem 0.75rem;
|
||||||
border: 0 !important;
|
|
||||||
background: transparent !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-theme-toggle:hover,
|
||||||
|
.brand-theme-toggle:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-theme-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--bulma-link);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-theme-logo {
|
.brand-theme-logo {
|
||||||
@@ -273,17 +689,120 @@ a.panel-block:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-theme-stroke {
|
.brand-theme-stroke {
|
||||||
stroke: #111827;
|
stroke: var(--gia-text);
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .brand-theme-stroke {
|
|
||||||
stroke: #f8fafc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-page-tabs a {
|
.security-page-tabs a {
|
||||||
transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
|
transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gia-settings-nav .tabs ul {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-settings-nav .tabs li {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-settings-nav .tabs a {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-send-composer {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--bulma-border, #dbdbdb);
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
background: var(--bulma-scheme-main-bis, #f7f8fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-send-composer-row {
|
||||||
|
align-items: stretch;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-send-composer-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-send-composer-input {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
max-height: 8rem;
|
||||||
|
resize: none;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-send-composer-action {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-send-composer-button {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-dropdown.gia-navbar-dropdown {
|
||||||
|
max-width: min(24rem, calc(100vw - 1rem));
|
||||||
|
max-height: min(80vh, 34rem) !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print, screen and (min-width: 1024px) {
|
||||||
|
.navbar-end .has-dropdown > .navbar-dropdown.gia-navbar-dropdown {
|
||||||
|
left: auto !important;
|
||||||
|
right: 0 !important;
|
||||||
|
inset-inline-start: auto !important;
|
||||||
|
inset-inline-end: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.section.gia-workspace-page {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-workspace-main {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-snap-assistant {
|
||||||
|
min-width: 0;
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-widget-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-send-composer-row {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-send-composer-input {
|
||||||
|
border-radius: var(--bulma-radius, 0.375rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-send-composer-action {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gia-send-composer-button {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--bulma-radius, 0.375rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.reduced-motion,
|
.reduced-motion,
|
||||||
.reduced-motion * {
|
.reduced-motion * {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
const data = document.currentScript.dataset;
|
|
||||||
const isDebug = data.debug === "True";
|
|
||||||
|
|
||||||
if (isDebug) {
|
|
||||||
document.addEventListener("htmx:beforeOnLoad", function (event) {
|
|
||||||
const xhr = event.detail.xhr;
|
|
||||||
if (xhr.status == 500 || xhr.status == 404) {
|
|
||||||
// Tell htmx to stop processing this response
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
document.children[0].innerHTML = xhr.response;
|
|
||||||
|
|
||||||
// Run Django’s inline script
|
|
||||||
// (1, eval) wtf - see https://stackoverflow.com/questions/9107240/1-evalthis-vs-evalthis-in-javascript
|
|
||||||
(1, eval)(document.scripts[0].innerText);
|
|
||||||
// Need to directly call Django’s onload function since browser won’t
|
|
||||||
window.onload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
core/static/js/bulma-calendar.min.js
vendored
1
core/static/js/bulma-calendar.min.js
vendored
File diff suppressed because one or more lines are too long
1
core/static/js/bulma-slider.min.js
vendored
1
core/static/js/bulma-slider.min.js
vendored
File diff suppressed because one or more lines are too long
1
core/static/js/bulma-tagsinput.min.js
vendored
1
core/static/js/bulma-tagsinput.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,259 +0,0 @@
|
|||||||
// Author: Grzegorz Tężycki
|
|
||||||
|
|
||||||
$(document).ready(function(){
|
|
||||||
|
|
||||||
// In web storage is saved structure like that:
|
|
||||||
// localStorage['django_tables2_column_shifter'] = {
|
|
||||||
// 'table_class_container1' : {
|
|
||||||
// 'id' : 'on',
|
|
||||||
// 'col1' : 'off',
|
|
||||||
// 'col2' : 'on',
|
|
||||||
// 'col3' : 'on',
|
|
||||||
// },
|
|
||||||
// 'table_class_container2' : {
|
|
||||||
// 'id' : 'on',
|
|
||||||
// 'col1' : 'on'
|
|
||||||
// },
|
|
||||||
// }
|
|
||||||
|
|
||||||
// main name for key in web storage
|
|
||||||
var COLUMN_SHIFTER_STORAGE_ACCESOR = "django_tables2_column_shifter";
|
|
||||||
|
|
||||||
// Return storage structure for shifter
|
|
||||||
// If structure does'n exist in web storage
|
|
||||||
// will be return empty object
|
|
||||||
var get_column_shifter_storage = function(){
|
|
||||||
var storage = localStorage.getItem(COLUMN_SHIFTER_STORAGE_ACCESOR);
|
|
||||||
if (storage === null) {
|
|
||||||
storage = {
|
|
||||||
"drilldown-table": {
|
|
||||||
"date": "off",
|
|
||||||
"time": "off",
|
|
||||||
"id": "off",
|
|
||||||
"host": "off",
|
|
||||||
"ident": "off",
|
|
||||||
"channel": "off",
|
|
||||||
"net": "off",
|
|
||||||
"num": "off",
|
|
||||||
"channel_nsfw": "off",
|
|
||||||
"channel_category": "off",
|
|
||||||
"channel_category_id": "off",
|
|
||||||
"channel_category_nsfw": "off",
|
|
||||||
"channel_id": "off",
|
|
||||||
"guild_member_count": "off",
|
|
||||||
"bot": "off",
|
|
||||||
"msg_id": "off",
|
|
||||||
"user": "off",
|
|
||||||
"net_id": "off",
|
|
||||||
"user_id": "off",
|
|
||||||
"nick_id": "off",
|
|
||||||
"status": "off",
|
|
||||||
"num_users": "off",
|
|
||||||
"num_chans": "off",
|
|
||||||
"exemption": "off",
|
|
||||||
// "version_sentiment": "off",
|
|
||||||
"sentiment": "off",
|
|
||||||
"num": "off",
|
|
||||||
"online": "off",
|
|
||||||
"mtype": "off",
|
|
||||||
"realname": "off",
|
|
||||||
"server": "off",
|
|
||||||
"mtype": "off",
|
|
||||||
"hidden": "off",
|
|
||||||
"filename": "off",
|
|
||||||
"file_md5": "off",
|
|
||||||
"file_ext": "off",
|
|
||||||
"file_size": "off",
|
|
||||||
"lang_code": "off",
|
|
||||||
"tokens": "off",
|
|
||||||
"rule_id": "off",
|
|
||||||
"index": "off",
|
|
||||||
"meta": "off",
|
|
||||||
"match_ts": "off",
|
|
||||||
"batch_id": "off"
|
|
||||||
//"lang_name": "off",
|
|
||||||
// "words_noun": "off",
|
|
||||||
// "words_adj": "off",
|
|
||||||
// "words_verb": "off",
|
|
||||||
// "words_adv": "off"
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
storage = JSON.parse(storage);
|
|
||||||
}
|
|
||||||
return storage;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save structure in web storage
|
|
||||||
var set_column_shifter_storage = function(storage){
|
|
||||||
var json_storage = JSON.stringify(storage)
|
|
||||||
localStorage.setItem(COLUMN_SHIFTER_STORAGE_ACCESOR, json_storage);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remember state for single button
|
|
||||||
var save_btn_state = function($btn){
|
|
||||||
|
|
||||||
// Take css class for container with table
|
|
||||||
var table_class_container = $btn.data("table-class-container");
|
|
||||||
// Take html object with table
|
|
||||||
var $table_class_container = $("#" + table_class_container);
|
|
||||||
// Take single button statne ("on" / "off")
|
|
||||||
var state = $btn.data("state");
|
|
||||||
// td-class is a real column name in table
|
|
||||||
var td_class = $btn.data("td-class");
|
|
||||||
var storage = get_column_shifter_storage();
|
|
||||||
// Table id
|
|
||||||
var id = $table_class_container.attr("id");
|
|
||||||
|
|
||||||
// Checking if the ID is already in storage
|
|
||||||
if (id in storage) {
|
|
||||||
data = storage[id]
|
|
||||||
} else {
|
|
||||||
data = {}
|
|
||||||
storage[id] = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save state for table column in storage
|
|
||||||
data[td_class] = state;
|
|
||||||
set_column_shifter_storage(storage);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load states for buttons from storage for single tabel
|
|
||||||
var load_states = function($table_class_container) {
|
|
||||||
var storage = get_column_shifter_storage();
|
|
||||||
// Table id
|
|
||||||
var id = $table_class_container.attr("id");
|
|
||||||
var data = {};
|
|
||||||
|
|
||||||
// Checking if the ID is already in storage
|
|
||||||
if (id in storage) {
|
|
||||||
data = storage[id]
|
|
||||||
|
|
||||||
// For each shifter button set state
|
|
||||||
$table_class_container.find(".btn-shift-column").each(function(){
|
|
||||||
var $btn = $(this);
|
|
||||||
var td_class = $btn.data("td-class");
|
|
||||||
|
|
||||||
// If name of column is in store then get state
|
|
||||||
// and set state
|
|
||||||
if (td_class in data) {
|
|
||||||
var state = data[td_class]
|
|
||||||
set_btn_state($btn, state);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show table content and hide spiner
|
|
||||||
var show_table_content = function($table_class_container){
|
|
||||||
$table_class_container.find("#loader").hide();
|
|
||||||
$table_class_container.find("#table-container").show();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load buttons states for all button in page
|
|
||||||
var load_state_for_all_containters = function(){
|
|
||||||
$(".column-shifter-container").each(function(){
|
|
||||||
$table_class_container = $(this);
|
|
||||||
|
|
||||||
// Load states for all buttons in single container
|
|
||||||
load_states($table_class_container);
|
|
||||||
|
|
||||||
// When states was loaded then table must be show and
|
|
||||||
// loader (spiner) must be hide
|
|
||||||
show_table_content($table_class_container);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// change visibility column for single button
|
|
||||||
// if button has state "on" then show column
|
|
||||||
// else then column will be hide
|
|
||||||
shift_column = function( $btn ){
|
|
||||||
// button state
|
|
||||||
var state = $btn.data("state");
|
|
||||||
|
|
||||||
// td-class is a real column name in table
|
|
||||||
var td_class = $btn.data("td-class");
|
|
||||||
var table_class_container = $btn.data("table-class-container");
|
|
||||||
var $table_class_container = $("#" + table_class_container);
|
|
||||||
var $table = $table_class_container.find("table");
|
|
||||||
var $cels = $table.find("." + td_class);
|
|
||||||
|
|
||||||
if ( state === "on" ) {
|
|
||||||
$cels.show();
|
|
||||||
} else {
|
|
||||||
$cels.hide();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Shift visibility for all columns
|
|
||||||
shift_columns = function(){
|
|
||||||
var cols = $(".btn-shift-column");
|
|
||||||
var i, len = cols.length;
|
|
||||||
for (i=0; i < len; i++) {
|
|
||||||
shift_column($(cols[i]));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set icon imgae visibility for button state
|
|
||||||
var set_icon_for_state = function( $btn, state ) {
|
|
||||||
if (state === "on") {
|
|
||||||
$btn.find("span.uncheck").hide();
|
|
||||||
$btn.find("span.check").show();
|
|
||||||
} else {
|
|
||||||
$btn.find("span.check").hide();
|
|
||||||
$btn.find("span.uncheck").show();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set state for single button
|
|
||||||
var set_btn_state = function($btn, state){
|
|
||||||
$btn.data('state', state);
|
|
||||||
set_icon_for_state($btn, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change state for single button
|
|
||||||
var change_btn_state = function($btn){
|
|
||||||
var state = $btn.data("state");
|
|
||||||
|
|
||||||
if (state === "on") {
|
|
||||||
state = "off"
|
|
||||||
} else {
|
|
||||||
state = "on"
|
|
||||||
}
|
|
||||||
set_btn_state($btn, state);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run show/hide when click on button
|
|
||||||
$(".btn-shift-column").on("click", function(event){
|
|
||||||
var $btn = $(this);
|
|
||||||
event.stopPropagation();
|
|
||||||
change_btn_state($btn);
|
|
||||||
shift_column($btn);
|
|
||||||
save_btn_state($btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load saved states for all tables
|
|
||||||
load_state_for_all_containters();
|
|
||||||
|
|
||||||
// show or hide columns based on data from web storage
|
|
||||||
shift_columns();
|
|
||||||
|
|
||||||
// Add API method for retrieving non-visible cols for table
|
|
||||||
// Pass the 0-based index of the table or leave the parameter
|
|
||||||
// empty to return the hidden cols for the 1st table found
|
|
||||||
$.django_tables2_column_shifter_hidden = function(idx) {
|
|
||||||
if(idx==undefined) {
|
|
||||||
idx = 0;
|
|
||||||
}
|
|
||||||
return $('#table-container').eq(idx).find('.btn-shift-column').filter(function(z) {
|
|
||||||
return $(this).data('state')=='off'
|
|
||||||
}).map(function(z) {
|
|
||||||
return $(this).data('td-class')
|
|
||||||
}).toArray();
|
|
||||||
}
|
|
||||||
const event = new Event('restore-scroll');
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
const event2 = new Event('load-widget-results');
|
|
||||||
document.dispatchEvent(event2);
|
|
||||||
|
|
||||||
});
|
|
||||||
139
core/static/js/compose-panel-core.js
Normal file
139
core/static/js/compose-panel-core.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
(function () {
|
||||||
|
if (window.GIAComposePanelCore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_SELECTOR = ".compose-shell[data-compose-panel='1']";
|
||||||
|
window.giaComposePanels = window.giaComposePanels || {};
|
||||||
|
|
||||||
|
const collectPanels = function (root) {
|
||||||
|
const panels = [];
|
||||||
|
if (!root) {
|
||||||
|
return panels;
|
||||||
|
}
|
||||||
|
if (root.matches && root.matches(PANEL_SELECTOR)) {
|
||||||
|
panels.push(root);
|
||||||
|
}
|
||||||
|
if (root.querySelectorAll) {
|
||||||
|
root.querySelectorAll(PANEL_SELECTOR).forEach(function (panel) {
|
||||||
|
panels.push(panel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return panels;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toInt = function (value) {
|
||||||
|
const parsed = parseInt(value || "0", 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseJsonSafe = function (value, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(value || ""));
|
||||||
|
} catch (_err) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNode = function (tagName, className, text) {
|
||||||
|
const node = document.createElement(tagName);
|
||||||
|
if (className) {
|
||||||
|
node.className = className;
|
||||||
|
}
|
||||||
|
if (text !== undefined && text !== null) {
|
||||||
|
node.textContent = String(text);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSnippet = function (value) {
|
||||||
|
const compact = String(value || "").replace(/\s+/g, " ").trim();
|
||||||
|
if (!compact) {
|
||||||
|
return "(no text)";
|
||||||
|
}
|
||||||
|
if (compact.length <= 120) {
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
return compact.slice(0, 117).trimEnd() + "...";
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleCase = function (value) {
|
||||||
|
const raw = String(value || "").trim().toLowerCase();
|
||||||
|
if (!raw) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (raw === "whatsapp") {
|
||||||
|
return "WhatsApp";
|
||||||
|
}
|
||||||
|
if (raw === "xmpp") {
|
||||||
|
return "XMPP";
|
||||||
|
}
|
||||||
|
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeIdentifierForService = function (service, identifier) {
|
||||||
|
const serviceKey = String(service || "").trim().toLowerCase();
|
||||||
|
const raw = String(identifier || "").trim();
|
||||||
|
if (serviceKey === "whatsapp" && raw.includes("@")) {
|
||||||
|
return raw.split("@", 1)[0].trim();
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildComposeUrl = function (renderMode, service, identifier, personId) {
|
||||||
|
const serviceKey = String(service || "").trim().toLowerCase();
|
||||||
|
const identifierValue = normalizeIdentifierForService(serviceKey, identifier);
|
||||||
|
if (!serviceKey || !identifierValue) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("service", serviceKey);
|
||||||
|
params.set("identifier", identifierValue);
|
||||||
|
if (personId) {
|
||||||
|
params.set("person", String(personId || "").trim());
|
||||||
|
}
|
||||||
|
return (renderMode === "page" ? "/compose/page/" : "/compose/widget/")
|
||||||
|
+ "?"
|
||||||
|
+ params.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseServiceMap = function (optionNode) {
|
||||||
|
const fallbackService = String(
|
||||||
|
(optionNode && optionNode.dataset && optionNode.dataset.service) || ""
|
||||||
|
).trim().toLowerCase();
|
||||||
|
const fallbackIdentifier = String((optionNode && optionNode.value) || "").trim();
|
||||||
|
const fallback = {};
|
||||||
|
if (fallbackService && fallbackIdentifier) {
|
||||||
|
fallback[fallbackService] = fallbackIdentifier;
|
||||||
|
}
|
||||||
|
if (!optionNode || !optionNode.dataset) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const parsed = parseJsonSafe(optionNode.dataset.serviceMap || "{}", fallback);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const normalized = {};
|
||||||
|
Object.keys(parsed).forEach(function (key) {
|
||||||
|
const serviceKey = String(key || "").trim().toLowerCase();
|
||||||
|
const identifierValue = String(parsed[key] || "").trim();
|
||||||
|
if (serviceKey && identifierValue) {
|
||||||
|
normalized[serviceKey] = identifierValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Object.keys(normalized).length ? normalized : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.GIAComposePanelCore = {
|
||||||
|
PANEL_SELECTOR: PANEL_SELECTOR,
|
||||||
|
buildComposeUrl: buildComposeUrl,
|
||||||
|
collectPanels: collectPanels,
|
||||||
|
createNode: createNode,
|
||||||
|
normalizeIdentifierForService: normalizeIdentifierForService,
|
||||||
|
normalizeSnippet: normalizeSnippet,
|
||||||
|
parseJsonSafe: parseJsonSafe,
|
||||||
|
parseServiceMap: parseServiceMap,
|
||||||
|
titleCase: titleCase,
|
||||||
|
toInt: toInt,
|
||||||
|
};
|
||||||
|
})();
|
||||||
321
core/static/js/compose-panel-send.js
Normal file
321
core/static/js/compose-panel-send.js
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
(function () {
|
||||||
|
if (window.GIAComposePanelSend) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = window.GIAComposePanelCore;
|
||||||
|
if (!core) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createController = function (config) {
|
||||||
|
const panel = config.panel;
|
||||||
|
const panelId = config.panelId;
|
||||||
|
const state = config.state;
|
||||||
|
const thread = config.thread;
|
||||||
|
const form = config.form;
|
||||||
|
const textarea = config.textarea;
|
||||||
|
const statusBox = config.statusBox;
|
||||||
|
const manualConfirm = config.manualConfirm;
|
||||||
|
const armInput = config.armInput;
|
||||||
|
const confirmInput = config.confirmInput;
|
||||||
|
const sendButton = config.sendButton;
|
||||||
|
const sendCapable = config.sendCapable;
|
||||||
|
const csrfToken = config.csrfToken;
|
||||||
|
const queryParams = config.queryParams;
|
||||||
|
const poll = config.poll;
|
||||||
|
const clearReplyTarget = config.clearReplyTarget;
|
||||||
|
const autosize = config.autosize;
|
||||||
|
const flashCompose = config.flashCompose;
|
||||||
|
const setStatus = config.setStatus;
|
||||||
|
|
||||||
|
let transientCancelButton = null;
|
||||||
|
let persistentCancelWrap = null;
|
||||||
|
|
||||||
|
const postFormJson = async function (url, params) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"X-CSRFToken": csrfToken,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Request failed");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelSendRequest = function (commandId) {
|
||||||
|
return postFormJson(
|
||||||
|
String(panel.dataset.cancelSendUrl || ""),
|
||||||
|
queryParams({ command_id: String(commandId || "") })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideTransientCancelButton = function () {
|
||||||
|
if (!transientCancelButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transientCancelButton.remove();
|
||||||
|
transientCancelButton = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showTransientCancelButton = function () {
|
||||||
|
if (!statusBox || transientCancelButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transientCancelButton = core.createNode(
|
||||||
|
"button",
|
||||||
|
"button is-danger is-light is-small compose-cancel-send-btn",
|
||||||
|
"Cancel Send"
|
||||||
|
);
|
||||||
|
transientCancelButton.type = "button";
|
||||||
|
transientCancelButton.addEventListener("click", async function () {
|
||||||
|
try {
|
||||||
|
await cancelSendRequest("");
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore cancel failures.
|
||||||
|
} finally {
|
||||||
|
hideTransientCancelButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
statusBox.appendChild(transientCancelButton);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hidePersistentCancelButton = function () {
|
||||||
|
if (!persistentCancelWrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistentCancelWrap.remove();
|
||||||
|
persistentCancelWrap = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPendingCommandPolling = function () {
|
||||||
|
if (state.pendingCommandPoll) {
|
||||||
|
clearInterval(state.pendingCommandPoll);
|
||||||
|
state.pendingCommandPoll = null;
|
||||||
|
}
|
||||||
|
state.pendingCommandId = "";
|
||||||
|
state.pendingCommandAttempts = 0;
|
||||||
|
state.pendingCommandStartedAt = 0;
|
||||||
|
state.pendingCommandInFlight = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPersistentCancelButton = function (commandId) {
|
||||||
|
hidePersistentCancelButton();
|
||||||
|
if (!statusBox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistentCancelWrap = core.createNode("div", "compose-persistent-cancel");
|
||||||
|
const button = core.createNode(
|
||||||
|
"button",
|
||||||
|
"button is-danger is-light is-small compose-persistent-cancel-btn",
|
||||||
|
"Cancel Queued Send"
|
||||||
|
);
|
||||||
|
button.type = "button";
|
||||||
|
button.addEventListener("click", async function () {
|
||||||
|
try {
|
||||||
|
await cancelSendRequest(commandId);
|
||||||
|
stopPendingCommandPolling();
|
||||||
|
hidePersistentCancelButton();
|
||||||
|
setStatus("Send cancelled.", "warning");
|
||||||
|
await poll(true);
|
||||||
|
} catch (_err) {
|
||||||
|
hidePersistentCancelButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
persistentCancelWrap.appendChild(button);
|
||||||
|
statusBox.appendChild(persistentCancelWrap);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollPendingCommandResult = async function (commandId) {
|
||||||
|
const url = new URL(
|
||||||
|
String(panel.dataset.commandResultUrl || ""),
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
url.searchParams.set("service", thread.dataset.service || "");
|
||||||
|
url.searchParams.set("command_id", commandId);
|
||||||
|
url.searchParams.set("format", "json");
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "HX-Request": "true" },
|
||||||
|
});
|
||||||
|
if (!response.ok || response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPendingCommandPolling = function (commandId) {
|
||||||
|
if (!commandId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopPendingCommandPolling();
|
||||||
|
state.pendingCommandId = commandId;
|
||||||
|
state.pendingCommandStartedAt = Date.now();
|
||||||
|
showPersistentCancelButton(commandId);
|
||||||
|
state.pendingCommandPoll = setInterval(async function () {
|
||||||
|
if (state.pendingCommandInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.pendingCommandAttempts += 1;
|
||||||
|
if (
|
||||||
|
state.pendingCommandAttempts > 14
|
||||||
|
|| (Date.now() - state.pendingCommandStartedAt) > 45000
|
||||||
|
) {
|
||||||
|
stopPendingCommandPolling();
|
||||||
|
hidePersistentCancelButton();
|
||||||
|
setStatus(
|
||||||
|
"Send timed out waiting for runtime result. Please retry.",
|
||||||
|
"warning"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
state.pendingCommandInFlight = true;
|
||||||
|
const payload = await pollPendingCommandResult(commandId);
|
||||||
|
if (!payload || payload.pending !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = payload.result || {};
|
||||||
|
stopPendingCommandPolling();
|
||||||
|
hidePersistentCancelButton();
|
||||||
|
if (result.ok) {
|
||||||
|
setStatus("", "success");
|
||||||
|
textarea.value = "";
|
||||||
|
clearReplyTarget();
|
||||||
|
autosize();
|
||||||
|
flashCompose("is-send-success");
|
||||||
|
await poll(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(String(result.error || "Send failed."), "danger");
|
||||||
|
flashCompose("is-send-fail");
|
||||||
|
await poll(true);
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore transient failures; the next poll can recover.
|
||||||
|
} finally {
|
||||||
|
state.pendingCommandInFlight = false;
|
||||||
|
}
|
||||||
|
}, 3500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateManualSafety = function () {
|
||||||
|
const confirmed = !!(manualConfirm && manualConfirm.checked);
|
||||||
|
if (armInput) {
|
||||||
|
armInput.value = confirmed ? "1" : "0";
|
||||||
|
}
|
||||||
|
if (confirmInput) {
|
||||||
|
confirmInput.value = confirmed ? "1" : "0";
|
||||||
|
}
|
||||||
|
if (sendButton) {
|
||||||
|
sendButton.disabled = !sendCapable || !confirmed;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindSendEvents = function () {
|
||||||
|
textarea.addEventListener("keydown", function (event) {
|
||||||
|
if (event.key !== "Enter" || event.shiftKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (sendButton && sendButton.disabled) {
|
||||||
|
setStatus("Enable send confirmation before sending.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.requestSubmit();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", function () {
|
||||||
|
if (sendButton && sendButton.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showTransientCancelButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("htmx:afterRequest", function () {
|
||||||
|
hideTransientCancelButton();
|
||||||
|
textarea.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindDocumentEvents = function () {
|
||||||
|
state.eventHandler = function (event) {
|
||||||
|
const detail = (event && event.detail) || {};
|
||||||
|
const sourcePanelId = String(detail.panel_id || "");
|
||||||
|
if (sourcePanelId && sourcePanelId !== panelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
poll(true);
|
||||||
|
};
|
||||||
|
document.body.addEventListener("composeMessageSent", state.eventHandler);
|
||||||
|
|
||||||
|
state.sendResultHandler = function (event) {
|
||||||
|
const detail = (event && event.detail) || {};
|
||||||
|
const sourcePanelId = String(detail.panel_id || "");
|
||||||
|
if (sourcePanelId && sourcePanelId !== panelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideTransientCancelButton();
|
||||||
|
if (detail.ok) {
|
||||||
|
flashCompose("is-send-success");
|
||||||
|
textarea.value = "";
|
||||||
|
clearReplyTarget();
|
||||||
|
autosize();
|
||||||
|
poll(true);
|
||||||
|
} else {
|
||||||
|
flashCompose("is-send-fail");
|
||||||
|
if (detail.message) {
|
||||||
|
setStatus(detail.message, detail.level || "danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textarea.focus();
|
||||||
|
};
|
||||||
|
document.body.addEventListener("composeSendResult", state.sendResultHandler);
|
||||||
|
|
||||||
|
state.commandIdHandler = function (event) {
|
||||||
|
const detail = (event && event.detail) || {};
|
||||||
|
const commandId = String(
|
||||||
|
detail.command_id
|
||||||
|
|| (detail.composeSendCommandId && detail.composeSendCommandId.command_id)
|
||||||
|
|| ""
|
||||||
|
).trim();
|
||||||
|
if (commandId) {
|
||||||
|
startPendingCommandPolling(commandId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.body.addEventListener(
|
||||||
|
"composeSendCommandId",
|
||||||
|
state.commandIdHandler
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = function () {
|
||||||
|
bindSendEvents();
|
||||||
|
bindDocumentEvents();
|
||||||
|
if (manualConfirm) {
|
||||||
|
manualConfirm.addEventListener("change", updateManualSafety);
|
||||||
|
manualConfirm.dispatchEvent(new Event("change"));
|
||||||
|
} else {
|
||||||
|
updateManualSafety();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
resetForContextSwitch: function () {
|
||||||
|
stopPendingCommandPolling();
|
||||||
|
hidePersistentCancelButton();
|
||||||
|
hideTransientCancelButton();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.GIAComposePanelSend = {
|
||||||
|
createController: createController,
|
||||||
|
};
|
||||||
|
})();
|
||||||
504
core/static/js/compose-panel-thread.js
Normal file
504
core/static/js/compose-panel-thread.js
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
(function () {
|
||||||
|
if (window.GIAComposePanelThread) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = window.GIAComposePanelCore;
|
||||||
|
if (!core) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createController = function (config) {
|
||||||
|
const panel = config.panel;
|
||||||
|
const state = config.state;
|
||||||
|
const thread = config.thread;
|
||||||
|
const textarea = config.textarea;
|
||||||
|
const typingNode = config.typingNode;
|
||||||
|
const hiddenReplyTo = config.hiddenReplyTo;
|
||||||
|
const replyBanner = config.replyBanner;
|
||||||
|
const replyBannerText = config.replyBannerText;
|
||||||
|
const replyClearBtn = config.replyClearBtn;
|
||||||
|
const platformSelect = config.platformSelect;
|
||||||
|
const contactSelect = config.contactSelect;
|
||||||
|
const hiddenService = config.hiddenService;
|
||||||
|
const hiddenIdentifier = config.hiddenIdentifier;
|
||||||
|
const hiddenPerson = config.hiddenPerson;
|
||||||
|
const metaLine = config.metaLine;
|
||||||
|
const renderMode = config.renderMode;
|
||||||
|
|
||||||
|
let lastTs = core.toInt(thread.dataset.lastTs);
|
||||||
|
let beforeContextReset = null;
|
||||||
|
|
||||||
|
const nearBottom = function () {
|
||||||
|
return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToBottom = function (force) {
|
||||||
|
if (force || nearBottom()) {
|
||||||
|
thread.scrollTop = thread.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryParams = function (extraParams) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("service", thread.dataset.service || "");
|
||||||
|
params.set("identifier", thread.dataset.identifier || "");
|
||||||
|
if (thread.dataset.person) {
|
||||||
|
params.set("person", thread.dataset.person);
|
||||||
|
}
|
||||||
|
params.set("limit", thread.dataset.limit || "60");
|
||||||
|
const extras =
|
||||||
|
extraParams && typeof extraParams === "object" ? extraParams : {};
|
||||||
|
Object.keys(extras).forEach(function (key) {
|
||||||
|
const value = extras[key];
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
params.set(String(key), String(value));
|
||||||
|
});
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureEmptyState = function (messageText) {
|
||||||
|
if (thread.querySelector(".compose-row")) {
|
||||||
|
const empty = thread.querySelector(".compose-empty");
|
||||||
|
if (empty) {
|
||||||
|
empty.remove();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let empty = thread.querySelector(".compose-empty");
|
||||||
|
if (!empty) {
|
||||||
|
empty = core.createNode("p", "compose-empty");
|
||||||
|
thread.appendChild(empty);
|
||||||
|
}
|
||||||
|
empty.textContent = String(
|
||||||
|
messageText || "No stored messages for this contact yet."
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowByMessageId = function (messageId) {
|
||||||
|
const targetId = String(messageId || "").trim();
|
||||||
|
if (!targetId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return thread.querySelector(
|
||||||
|
'.compose-row[data-message-id="' + targetId + '"]'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReplySelectionClass = function () {
|
||||||
|
thread
|
||||||
|
.querySelectorAll(".compose-row.compose-reply-selected")
|
||||||
|
.forEach(function (row) {
|
||||||
|
row.classList.remove("compose-reply-selected");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const flashReplyTarget = function (row) {
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.classList.remove("is-target-flash");
|
||||||
|
void row.offsetWidth;
|
||||||
|
row.classList.add("is-target-flash");
|
||||||
|
window.setTimeout(function () {
|
||||||
|
row.classList.remove("is-target-flash");
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReplyTarget = function () {
|
||||||
|
if (hiddenReplyTo) {
|
||||||
|
hiddenReplyTo.value = "";
|
||||||
|
}
|
||||||
|
if (replyBanner) {
|
||||||
|
replyBanner.classList.add("is-hidden");
|
||||||
|
}
|
||||||
|
if (replyBannerText) {
|
||||||
|
replyBannerText.textContent = "";
|
||||||
|
}
|
||||||
|
clearReplySelectionClass();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReplyTarget = function (messageId, preview) {
|
||||||
|
const targetId = String(messageId || "").trim();
|
||||||
|
if (!targetId) {
|
||||||
|
clearReplyTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = rowByMessageId(targetId);
|
||||||
|
const snippet = core.normalizeSnippet(
|
||||||
|
(row && row.dataset ? row.dataset.replySnippet : "") || preview || ""
|
||||||
|
);
|
||||||
|
if (hiddenReplyTo) {
|
||||||
|
hiddenReplyTo.value = targetId;
|
||||||
|
}
|
||||||
|
if (replyBannerText) {
|
||||||
|
replyBannerText.textContent = snippet;
|
||||||
|
}
|
||||||
|
if (replyBanner) {
|
||||||
|
replyBanner.classList.remove("is-hidden");
|
||||||
|
}
|
||||||
|
clearReplySelectionClass();
|
||||||
|
if (row) {
|
||||||
|
row.classList.add("compose-reply-selected");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseMessageRows = function (html) {
|
||||||
|
const markup = String(html || "").trim();
|
||||||
|
if (!markup) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const template = document.createElement("template");
|
||||||
|
template.innerHTML = markup;
|
||||||
|
return Array.from(template.content.querySelectorAll(".compose-row"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertRowByTs = function (row) {
|
||||||
|
const newTs = core.toInt(row.dataset.ts);
|
||||||
|
const rows = Array.from(thread.querySelectorAll(".compose-row"));
|
||||||
|
if (!rows.length) {
|
||||||
|
thread.appendChild(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||||
|
const existing = rows[index];
|
||||||
|
if (core.toInt(existing.dataset.ts) <= newTs) {
|
||||||
|
if (existing.nextSibling) {
|
||||||
|
thread.insertBefore(row, existing.nextSibling);
|
||||||
|
} else {
|
||||||
|
thread.appendChild(row);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thread.insertBefore(row, rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertMessageRow = function (row) {
|
||||||
|
if (!row || !row.classList || !row.classList.contains("compose-row")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messageId = String((row.dataset && row.dataset.messageId) || "").trim();
|
||||||
|
if (messageId) {
|
||||||
|
const existing = rowByMessageId(messageId);
|
||||||
|
if (existing) {
|
||||||
|
existing.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const empty = thread.querySelector(".compose-empty");
|
||||||
|
if (empty) {
|
||||||
|
empty.remove();
|
||||||
|
}
|
||||||
|
insertRowByTs(row);
|
||||||
|
lastTs = Math.max(lastTs, core.toInt(row.dataset.ts));
|
||||||
|
thread.dataset.lastTs = String(lastTs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendMessageHtml = function (html, forceScroll) {
|
||||||
|
const rows = parseMessageRows(html);
|
||||||
|
const shouldStick = forceScroll || nearBottom();
|
||||||
|
rows.forEach(function (msg) {
|
||||||
|
upsertMessageRow(msg);
|
||||||
|
});
|
||||||
|
if (rows.length) {
|
||||||
|
scrollToBottom(shouldStick);
|
||||||
|
}
|
||||||
|
ensureEmptyState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTyping = function (payload) {
|
||||||
|
if (!typingNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typingPayload =
|
||||||
|
payload && typeof payload === "object" ? payload : {};
|
||||||
|
if (!typingPayload.typing) {
|
||||||
|
typingNode.classList.add("is-hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const displayName = String(typingPayload.display_name || "").trim();
|
||||||
|
typingNode.textContent = (displayName || "Contact") + " is typing...";
|
||||||
|
typingNode.classList.remove("is-hidden");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSocket = function () {
|
||||||
|
if (!state.socket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
state.socket.close();
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore close failures.
|
||||||
|
}
|
||||||
|
state.socket = null;
|
||||||
|
state.websocketReady = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const poll = async function (forceScroll) {
|
||||||
|
if (state.polling || state.websocketReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.polling = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
thread.dataset.pollUrl + "?" + queryParams({ after_ts: String(lastTs) }),
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
appendMessageHtml(payload.messages_html || "", forceScroll);
|
||||||
|
applyTyping(payload.typing);
|
||||||
|
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||||
|
lastTs = Math.max(lastTs, core.toInt(payload.last_ts));
|
||||||
|
thread.dataset.lastTs = String(lastTs);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.debug("compose poll error", err);
|
||||||
|
} finally {
|
||||||
|
state.polling = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupWebSocket = function () {
|
||||||
|
const wsPath = String(thread.dataset.wsUrl || "").trim();
|
||||||
|
if (!wsPath || !window.WebSocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||||
|
const socket = new WebSocket(protocol + window.location.host + wsPath);
|
||||||
|
state.socket = socket;
|
||||||
|
socket.onopen = function () {
|
||||||
|
state.websocketReady = true;
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify({ kind: "sync", last_ts: lastTs }));
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore sync send errors.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onmessage = function (event) {
|
||||||
|
const payload = core.parseJsonSafe(event.data || "{}", {});
|
||||||
|
appendMessageHtml(payload.messages_html || "", false);
|
||||||
|
applyTyping(payload.typing);
|
||||||
|
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||||
|
lastTs = Math.max(lastTs, core.toInt(payload.last_ts));
|
||||||
|
thread.dataset.lastTs = String(lastTs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onclose = function () {
|
||||||
|
state.websocketReady = false;
|
||||||
|
if (state.socket === socket) {
|
||||||
|
state.socket = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onerror = function () {
|
||||||
|
state.websocketReady = false;
|
||||||
|
};
|
||||||
|
} catch (_err) {
|
||||||
|
state.websocketReady = false;
|
||||||
|
state.socket = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchThreadContext = function (
|
||||||
|
nextService,
|
||||||
|
nextIdentifier,
|
||||||
|
nextPersonId,
|
||||||
|
nextUrl
|
||||||
|
) {
|
||||||
|
const service = String(nextService || "").trim().toLowerCase();
|
||||||
|
const identifier = core.normalizeIdentifierForService(
|
||||||
|
service,
|
||||||
|
nextIdentifier
|
||||||
|
);
|
||||||
|
const personId = String(nextPersonId || "").trim();
|
||||||
|
if (!service || !identifier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (renderMode === "page" && nextUrl) {
|
||||||
|
window.location.assign(String(nextUrl));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
String(thread.dataset.service || "").toLowerCase() === service
|
||||||
|
&& String(thread.dataset.identifier || "") === identifier
|
||||||
|
&& String(thread.dataset.person || "") === personId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof beforeContextReset === "function") {
|
||||||
|
beforeContextReset();
|
||||||
|
}
|
||||||
|
thread.dataset.service = service;
|
||||||
|
thread.dataset.identifier = identifier;
|
||||||
|
if (personId) {
|
||||||
|
thread.dataset.person = personId;
|
||||||
|
} else {
|
||||||
|
delete thread.dataset.person;
|
||||||
|
}
|
||||||
|
if (hiddenService) {
|
||||||
|
hiddenService.value = service;
|
||||||
|
}
|
||||||
|
if (hiddenIdentifier) {
|
||||||
|
hiddenIdentifier.value = identifier;
|
||||||
|
}
|
||||||
|
if (hiddenPerson) {
|
||||||
|
hiddenPerson.value = personId;
|
||||||
|
}
|
||||||
|
if (metaLine) {
|
||||||
|
metaLine.textContent = core.titleCase(service) + " · " + identifier;
|
||||||
|
}
|
||||||
|
clearReplyTarget();
|
||||||
|
closeSocket();
|
||||||
|
lastTs = 0;
|
||||||
|
thread.dataset.lastTs = "0";
|
||||||
|
thread.innerHTML = "";
|
||||||
|
ensureEmptyState("Loading messages...");
|
||||||
|
applyTyping({ typing: false });
|
||||||
|
poll(true);
|
||||||
|
setupWebSocket();
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindContextSelectors = function () {
|
||||||
|
if (platformSelect) {
|
||||||
|
platformSelect.addEventListener("change", function () {
|
||||||
|
const selected = platformSelect.options[platformSelect.selectedIndex];
|
||||||
|
if (!selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetUrl = core.buildComposeUrl(
|
||||||
|
renderMode,
|
||||||
|
selected.value || "",
|
||||||
|
selected.dataset.identifier || "",
|
||||||
|
selected.dataset.person || ""
|
||||||
|
);
|
||||||
|
switchThreadContext(
|
||||||
|
selected.value || "",
|
||||||
|
selected.dataset.identifier || "",
|
||||||
|
selected.dataset.person || "",
|
||||||
|
targetUrl
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contactSelect) {
|
||||||
|
contactSelect.addEventListener("change", function () {
|
||||||
|
const selected = contactSelect.options[contactSelect.selectedIndex];
|
||||||
|
if (!selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serviceMap = core.parseServiceMap(selected);
|
||||||
|
const currentService = String(thread.dataset.service || "").toLowerCase();
|
||||||
|
const availableServices = Object.keys(serviceMap);
|
||||||
|
let selectedService = currentService || String(selected.dataset.service || "");
|
||||||
|
let selectedIdentifier = String(serviceMap[selectedService] || "").trim();
|
||||||
|
if (!selectedIdentifier) {
|
||||||
|
selectedService = String(
|
||||||
|
selected.dataset.service || selectedService
|
||||||
|
).trim().toLowerCase();
|
||||||
|
selectedIdentifier = String(serviceMap[selectedService] || "").trim();
|
||||||
|
}
|
||||||
|
if (!selectedIdentifier && availableServices.length) {
|
||||||
|
selectedService = availableServices[0];
|
||||||
|
selectedIdentifier = String(serviceMap[selectedService] || "").trim();
|
||||||
|
}
|
||||||
|
const targetUrl = core.buildComposeUrl(
|
||||||
|
renderMode,
|
||||||
|
selectedService,
|
||||||
|
selectedIdentifier,
|
||||||
|
selected.dataset.person || ""
|
||||||
|
);
|
||||||
|
switchThreadContext(
|
||||||
|
selectedService,
|
||||||
|
selectedIdentifier,
|
||||||
|
selected.dataset.person || "",
|
||||||
|
targetUrl
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindThreadEvents = function () {
|
||||||
|
if (replyClearBtn) {
|
||||||
|
replyClearBtn.addEventListener("click", function () {
|
||||||
|
clearReplyTarget();
|
||||||
|
textarea.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
thread.addEventListener("click", function (event) {
|
||||||
|
const replyLink =
|
||||||
|
event.target.closest && event.target.closest(".compose-reply-link");
|
||||||
|
if (replyLink) {
|
||||||
|
const replyRef = replyLink.closest(".compose-reply-ref");
|
||||||
|
const targetId = String(
|
||||||
|
(replyRef && replyRef.dataset && replyRef.dataset.replyTargetId) || ""
|
||||||
|
).trim();
|
||||||
|
if (!targetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetRow = rowByMessageId(targetId);
|
||||||
|
if (!targetRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targetRow.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
flashReplyTarget(targetRow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyButton =
|
||||||
|
event.target.closest && event.target.closest(".compose-reply-btn");
|
||||||
|
if (!replyButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = replyButton.closest(".compose-row");
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReplyTarget(row.dataset.messageId || "", row.dataset.replySnippet || "");
|
||||||
|
textarea.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = function () {
|
||||||
|
bindThreadEvents();
|
||||||
|
bindContextSelectors();
|
||||||
|
applyTyping(core.parseJsonSafe(panel.dataset.initialTyping || "{}", {}));
|
||||||
|
ensureEmptyState();
|
||||||
|
scrollToBottom(true);
|
||||||
|
setupWebSocket();
|
||||||
|
|
||||||
|
state.pollTimer = setInterval(function () {
|
||||||
|
if (!document.getElementById(config.panelId)) {
|
||||||
|
if (window.GIAComposePanel) {
|
||||||
|
window.GIAComposePanel.destroyPanel(config.panelId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
poll(false);
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
clearReplyTarget: clearReplyTarget,
|
||||||
|
init: init,
|
||||||
|
poll: poll,
|
||||||
|
queryParams: queryParams,
|
||||||
|
setBeforeContextReset: function (callback) {
|
||||||
|
beforeContextReset = callback;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.GIAComposePanelThread = {
|
||||||
|
createController: createController,
|
||||||
|
};
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
16
core/static/js/gridstack.min.js
vendored
16
core/static/js/gridstack.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
core/static/js/hyperscript.min.js
vendored
1
core/static/js/hyperscript.min.js
vendored
File diff suppressed because one or more lines are too long
2
core/static/js/jquery.min.js
vendored
2
core/static/js/jquery.min.js
vendored
File diff suppressed because one or more lines are too long
2
core/static/js/magnet.min.js
vendored
2
core/static/js/magnet.min.js
vendored
File diff suppressed because one or more lines are too long
1182
core/static/js/workspace-shell.js
Normal file
1182
core/static/js/workspace-shell.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
|||||||
<svg width="800" height="800" viewBox="0 0 213.35 150.85" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(38.831 -7.4316)">
|
|
||||||
<g transform="matrix(.99287 0 0 .99911 1.2367 -30.308)">
|
|
||||||
<path d="m-32.645 113.03a115.16 122.5 0 0 1 99.73-61.25 115.16 122.5 0 0 1 99.73 61.25" fill="none" stroke="#f8fafc" stroke-linecap="round" stroke-linejoin="round" stroke-opacity=".99505" stroke-width="15.42" style="paint-order:markers fill stroke"/>
|
|
||||||
<path d="m67.006 89.591a42.374 42.374 0 0 1 39.148 26.158 42.374 42.374 0 0 1-9.1855 46.179 42.374 42.374 0 0 1-46.179 9.1855 42.374 42.374 0 0 1-26.158-39.148" fill="none" stroke="#f8fafc" stroke-linecap="round" stroke-miterlimit="3.4" stroke-width="16.251"/>
|
|
||||||
<circle cx="67.003" cy="131.96" r="13.151" fill="#740101"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 812 B |
@@ -1,9 +0,0 @@
|
|||||||
<svg width="800" height="800" viewBox="0 0 213.35 150.85" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(38.831 -7.4316)">
|
|
||||||
<g transform="matrix(.99287 0 0 .99911 1.2367 -30.308)">
|
|
||||||
<path d="m-32.645 113.03a115.16 122.5 0 0 1 99.73-61.25 115.16 122.5 0 0 1 99.73 61.25" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-opacity=".99505" stroke-width="15.42" style="paint-order:markers fill stroke"/>
|
|
||||||
<path d="m67.006 89.591a42.374 42.374 0 0 1 39.148 26.158 42.374 42.374 0 0 1-9.1855 46.179 42.374 42.374 0 0 1-46.179 9.1855 42.374 42.374 0 0 1-26.158-39.148" fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="3.4" stroke-width="16.251"/>
|
|
||||||
<circle cx="67.003" cy="131.96" r="13.151" fill="#740101"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 806 B |
@@ -1,9 +0,0 @@
|
|||||||
<svg width="800" height="800" version="1.1" viewBox="0 0 211.7 211.7" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(38.55 -16.23)">
|
|
||||||
<g transform="translate(0 .001548)">
|
|
||||||
<path d="m-31.79 121.8a114.4 122.5 0 0 1 99.07-61.27 114.4 122.5 0 0 1 99.07 61.27" fill="none" stroke="#000" stroke-linejoin="round" stroke-opacity=".9951" stroke-width="15.37" style="paint-order:markers fill stroke"/>
|
|
||||||
<path d="m67.28 98.38a42.09 42.39 0 0 1 38.89 26.17 42.09 42.39 0 0 1-9.125 46.2 42.09 42.39 0 0 1-45.87 9.189 42.09 42.39 0 0 1-25.98-39.16" fill="none" stroke="#000" stroke-miterlimit="3.4" stroke-width="16.2"/>
|
|
||||||
<ellipse cx="67.28" cy="140.8" rx="13.06" ry="13.16" fill="#740101"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 719 B |
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from core.models import ChatTaskSource, TaskProject
|
from core.models import ChatTaskSource, TaskProject
|
||||||
from core.tasks.codex_support import channel_variants
|
|
||||||
|
|
||||||
SAFE_TASK_FLAGS_DEFAULTS = {
|
SAFE_TASK_FLAGS_DEFAULTS = {
|
||||||
"derive_enabled": True,
|
"derive_enabled": True,
|
||||||
@@ -28,6 +27,33 @@ SIGNAL_PHONE_RE = re.compile(r"^\+\d+$")
|
|||||||
SIGNAL_INTERNAL_ID_RE = re.compile(r"^[A-Za-z0-9+/=]+$")
|
SIGNAL_INTERNAL_ID_RE = re.compile(r"^[A-Za-z0-9+/=]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def channel_variants(service: str, channel: str) -> list[str]:
|
||||||
|
value = str(channel or "").strip()
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
variants = [value]
|
||||||
|
service_key = str(service or "").strip().lower()
|
||||||
|
if service_key == "whatsapp":
|
||||||
|
bare = value.split("@", 1)[0].strip()
|
||||||
|
if bare and bare not in variants:
|
||||||
|
variants.append(bare)
|
||||||
|
direct = f"{bare}@s.whatsapp.net" if bare else ""
|
||||||
|
if direct and direct not in variants:
|
||||||
|
variants.append(direct)
|
||||||
|
group = f"{bare}@g.us" if bare else ""
|
||||||
|
if group and group not in variants:
|
||||||
|
variants.append(group)
|
||||||
|
if service_key == "signal":
|
||||||
|
digits = re.sub(r"[^0-9]", "", value)
|
||||||
|
if digits and digits not in variants:
|
||||||
|
variants.append(digits)
|
||||||
|
if digits:
|
||||||
|
plus = f"+{digits}"
|
||||||
|
if plus not in variants:
|
||||||
|
variants.append(plus)
|
||||||
|
return variants
|
||||||
|
|
||||||
|
|
||||||
def _normalize_whatsapp_identifier(identifier: str) -> str:
|
def _normalize_whatsapp_identifier(identifier: str) -> str:
|
||||||
value = str(identifier or "").strip()
|
value = str(identifier or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
|
|
||||||
from core.clients.transport import send_message_raw
|
|
||||||
from core.models import CodexPermissionRequest, ExternalSyncEvent, TaskProviderConfig
|
|
||||||
|
|
||||||
|
|
||||||
def _deterministic_approval_key(idempotency_key: str) -> str:
|
|
||||||
digest = hashlib.sha1(str(idempotency_key or "").encode("utf-8")).hexdigest()[:12]
|
|
||||||
return f"pre-{digest}"
|
|
||||||
|
|
||||||
|
|
||||||
def queue_codex_event_with_pre_approval(
|
|
||||||
*,
|
|
||||||
user,
|
|
||||||
run,
|
|
||||||
task,
|
|
||||||
task_event,
|
|
||||||
action: str,
|
|
||||||
provider_payload: dict,
|
|
||||||
idempotency_key: str,
|
|
||||||
provider: str = "codex_cli",
|
|
||||||
) -> tuple[ExternalSyncEvent, CodexPermissionRequest]:
|
|
||||||
provider = str(provider or "codex_cli").strip() or "codex_cli"
|
|
||||||
approval_key = _deterministic_approval_key(idempotency_key)
|
|
||||||
waiting_event, _ = ExternalSyncEvent.objects.update_or_create(
|
|
||||||
idempotency_key=f"codex_waiting:{idempotency_key}",
|
|
||||||
defaults={
|
|
||||||
"user": user,
|
|
||||||
"task": task,
|
|
||||||
"task_event": task_event,
|
|
||||||
"provider": provider,
|
|
||||||
"status": "waiting_approval",
|
|
||||||
"payload": {
|
|
||||||
"action": str(action or "append_update"),
|
|
||||||
"provider_payload": dict(provider_payload or {}),
|
|
||||||
},
|
|
||||||
"error": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
run.status = "waiting_approval"
|
|
||||||
run.error = ""
|
|
||||||
run.save(update_fields=["status", "error", "updated_at"])
|
|
||||||
|
|
||||||
provider_label = "Claude" if provider == "claude_cli" else "Codex"
|
|
||||||
xmpp_cmd = ".claude" if provider == "claude_cli" else ".codex"
|
|
||||||
request, _ = CodexPermissionRequest.objects.update_or_create(
|
|
||||||
approval_key=approval_key,
|
|
||||||
defaults={
|
|
||||||
"user": user,
|
|
||||||
"codex_run": run,
|
|
||||||
"external_sync_event": waiting_event,
|
|
||||||
"summary": f"Pre-submit approval required before sending to {provider_label}",
|
|
||||||
"requested_permissions": {
|
|
||||||
"type": "pre_submit",
|
|
||||||
"provider": provider,
|
|
||||||
"action": str(action or "append_update"),
|
|
||||||
},
|
|
||||||
"resume_payload": {
|
|
||||||
"gate_type": "pre_submit",
|
|
||||||
"action": str(action or "append_update"),
|
|
||||||
"provider_payload": dict(provider_payload or {}),
|
|
||||||
"idempotency_key": str(idempotency_key or ""),
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"resolved_at": None,
|
|
||||||
"resolved_by_identifier": "",
|
|
||||||
"resolution_note": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
cfg = TaskProviderConfig.objects.filter(
|
|
||||||
user=user, provider=provider, enabled=True
|
|
||||||
).first()
|
|
||||||
settings_payload = dict(getattr(cfg, "settings", {}) or {})
|
|
||||||
approver_service = (
|
|
||||||
str(settings_payload.get("approver_service") or "").strip().lower()
|
|
||||||
)
|
|
||||||
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip()
|
|
||||||
if approver_service and approver_identifier:
|
|
||||||
try:
|
|
||||||
async_to_sync(send_message_raw)(
|
|
||||||
approver_service,
|
|
||||||
approver_identifier,
|
|
||||||
text=(
|
|
||||||
f"[{provider} approval] key={approval_key}\n"
|
|
||||||
f"summary=Pre-submit approval required before sending to {provider_label}\n"
|
|
||||||
"requested=pre_submit\n"
|
|
||||||
f"use: {xmpp_cmd} approve {approval_key} or {xmpp_cmd} deny {approval_key}"
|
|
||||||
),
|
|
||||||
attachments=[],
|
|
||||||
metadata={"origin_tag": f"codex-pre-approval:{approval_key}"},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return waiting_event, request
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
from core.models import ExternalChatLink, PersonIdentifier
|
|
||||||
|
|
||||||
|
|
||||||
def channel_variants(service: str, channel: str) -> list[str]:
|
|
||||||
value = str(channel or "").strip()
|
|
||||||
if not value:
|
|
||||||
return []
|
|
||||||
variants = [value]
|
|
||||||
service_key = str(service or "").strip().lower()
|
|
||||||
if service_key == "whatsapp":
|
|
||||||
bare = value.split("@", 1)[0].strip()
|
|
||||||
if bare and bare not in variants:
|
|
||||||
variants.append(bare)
|
|
||||||
direct = f"{bare}@s.whatsapp.net" if bare else ""
|
|
||||||
if direct and direct not in variants:
|
|
||||||
variants.append(direct)
|
|
||||||
group = f"{bare}@g.us" if bare else ""
|
|
||||||
if group and group not in variants:
|
|
||||||
variants.append(group)
|
|
||||||
if service_key == "signal":
|
|
||||||
digits = re.sub(r"[^0-9]", "", value)
|
|
||||||
if digits and digits not in variants:
|
|
||||||
variants.append(digits)
|
|
||||||
if digits:
|
|
||||||
plus = f"+{digits}"
|
|
||||||
if plus not in variants:
|
|
||||||
variants.append(plus)
|
|
||||||
return variants
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_external_chat_id(*, user, provider: str, service: str, channel: str) -> str:
|
|
||||||
variants = channel_variants(service, channel)
|
|
||||||
if not variants:
|
|
||||||
return ""
|
|
||||||
person_identifier = (
|
|
||||||
PersonIdentifier.objects.filter(
|
|
||||||
user=user,
|
|
||||||
service=service,
|
|
||||||
identifier__in=variants,
|
|
||||||
)
|
|
||||||
.select_related("person")
|
|
||||||
.order_by("-id")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if person_identifier is None:
|
|
||||||
return ""
|
|
||||||
link = (
|
|
||||||
ExternalChatLink.objects.filter(
|
|
||||||
user=user,
|
|
||||||
provider=provider,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
Q(person_identifier=person_identifier) | Q(person=person_identifier.person)
|
|
||||||
)
|
|
||||||
.order_by("-updated_at", "-id")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return str(getattr(link, "external_chat_id", "") or "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def compact_json_snippet(payload: Any, limit: int = 800) -> str:
|
|
||||||
text = str(payload or "").strip()
|
|
||||||
if len(text) <= limit:
|
|
||||||
return text
|
|
||||||
return text[:limit].rstrip() + "..."
|
|
||||||
@@ -13,7 +13,6 @@ from core.models import (
|
|||||||
AI,
|
AI,
|
||||||
Chat,
|
Chat,
|
||||||
ChatTaskSource,
|
ChatTaskSource,
|
||||||
CodexRun,
|
|
||||||
DerivedTask,
|
DerivedTask,
|
||||||
DerivedTaskEvent,
|
DerivedTaskEvent,
|
||||||
ExternalSyncEvent,
|
ExternalSyncEvent,
|
||||||
@@ -27,8 +26,6 @@ from core.tasks.chat_defaults import (
|
|||||||
ensure_default_source_for_chat,
|
ensure_default_source_for_chat,
|
||||||
resolve_message_scope,
|
resolve_message_scope,
|
||||||
)
|
)
|
||||||
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
|
|
||||||
from core.tasks.codex_support import resolve_external_chat_id
|
|
||||||
from core.tasks.providers import get_provider
|
from core.tasks.providers import get_provider
|
||||||
|
|
||||||
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
|
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
|
||||||
@@ -506,21 +503,30 @@ async def _derive_title_with_flags(message: Message, flags: dict) -> str:
|
|||||||
async def _emit_sync_event(
|
async def _emit_sync_event(
|
||||||
task: DerivedTask, event: DerivedTaskEvent, action: str
|
task: DerivedTask, event: DerivedTaskEvent, action: str
|
||||||
) -> None:
|
) -> None:
|
||||||
cfg = await sync_to_async(
|
def _select_provider_config():
|
||||||
lambda: TaskProviderConfig.objects.filter(user=task.user, enabled=True)
|
enabled_cfg = (
|
||||||
|
TaskProviderConfig.objects.filter(user=task.user, enabled=True)
|
||||||
.order_by("provider")
|
.order_by("provider")
|
||||||
.first()
|
.first()
|
||||||
)()
|
)
|
||||||
|
if enabled_cfg is not None:
|
||||||
|
return enabled_cfg
|
||||||
|
any_cfg_exists = TaskProviderConfig.objects.filter(user=task.user).exists()
|
||||||
|
if any_cfg_exists:
|
||||||
|
return None
|
||||||
|
return False
|
||||||
|
|
||||||
|
cfg = await sync_to_async(_select_provider_config)()
|
||||||
|
if cfg is None:
|
||||||
|
return
|
||||||
|
if cfg is False:
|
||||||
|
provider_name = "mock"
|
||||||
|
provider_settings = {}
|
||||||
|
else:
|
||||||
provider_name = str(getattr(cfg, "provider", "mock") or "mock")
|
provider_name = str(getattr(cfg, "provider", "mock") or "mock")
|
||||||
provider_settings = dict(getattr(cfg, "settings", {}) or {})
|
provider_settings = dict(getattr(cfg, "settings", {}) or {})
|
||||||
provider = get_provider(provider_name)
|
provider = get_provider(provider_name)
|
||||||
idempotency_key = f"{provider_name}:{task.id}:{event.id}"
|
idempotency_key = f"{provider_name}:{task.id}:{event.id}"
|
||||||
external_chat_id = await sync_to_async(resolve_external_chat_id)(
|
|
||||||
user=task.user,
|
|
||||||
provider=provider_name,
|
|
||||||
service=str(task.source_service or ""),
|
|
||||||
channel=str(task.source_channel or ""),
|
|
||||||
)
|
|
||||||
cached_project = task._state.fields_cache.get("project")
|
cached_project = task._state.fields_cache.get("project")
|
||||||
cached_epic = task._state.fields_cache.get("epic")
|
cached_epic = task._state.fields_cache.get("epic")
|
||||||
project_name = str(getattr(cached_project, "name", "") or "")
|
project_name = str(getattr(cached_project, "name", "") or "")
|
||||||
@@ -545,7 +551,6 @@ async def _emit_sync_event(
|
|||||||
"epic_name": epic_name,
|
"epic_name": epic_name,
|
||||||
"source_service": str(task.source_service or ""),
|
"source_service": str(task.source_service or ""),
|
||||||
"source_channel": str(task.source_channel or ""),
|
"source_channel": str(task.source_channel or ""),
|
||||||
"external_chat_id": external_chat_id,
|
|
||||||
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
|
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
|
||||||
"trigger_message_id": str(
|
"trigger_message_id": str(
|
||||||
getattr(event, "source_message_id", "")
|
getattr(event, "source_message_id", "")
|
||||||
@@ -556,56 +561,6 @@ async def _emit_sync_event(
|
|||||||
"payload": event.payload,
|
"payload": event.payload,
|
||||||
"memory_context": memory_context,
|
"memory_context": memory_context,
|
||||||
}
|
}
|
||||||
codex_run = await sync_to_async(CodexRun.objects.create)(
|
|
||||||
user=task.user,
|
|
||||||
task_id=task.id,
|
|
||||||
derived_task_event_id=event.id,
|
|
||||||
source_message_id=(event.source_message_id or task.origin_message_id),
|
|
||||||
project_id=task.project_id,
|
|
||||||
epic_id=task.epic_id,
|
|
||||||
source_service=str(task.source_service or ""),
|
|
||||||
source_channel=str(task.source_channel or ""),
|
|
||||||
external_chat_id=external_chat_id,
|
|
||||||
status="queued",
|
|
||||||
request_payload={
|
|
||||||
"action": action,
|
|
||||||
"provider_payload": dict(request_payload),
|
|
||||||
"idempotency_key": idempotency_key,
|
|
||||||
},
|
|
||||||
result_payload={},
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
request_payload["codex_run_id"] = str(codex_run.id)
|
|
||||||
|
|
||||||
# Worker-backed providers are queued and executed by `manage.py codex_worker`.
|
|
||||||
if bool(getattr(provider, "run_in_worker", False)):
|
|
||||||
if provider_name == "codex_cli":
|
|
||||||
await sync_to_async(queue_codex_event_with_pre_approval)(
|
|
||||||
user=task.user,
|
|
||||||
run=codex_run,
|
|
||||||
task=task,
|
|
||||||
task_event=event,
|
|
||||||
action=action,
|
|
||||||
provider_payload=dict(request_payload),
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
defaults={
|
|
||||||
"user": task.user,
|
|
||||||
"task": task,
|
|
||||||
"task_event": event,
|
|
||||||
"provider": provider_name,
|
|
||||||
"status": "pending",
|
|
||||||
"payload": {
|
|
||||||
"action": action,
|
|
||||||
"provider_payload": dict(request_payload),
|
|
||||||
},
|
|
||||||
"error": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if action == "create":
|
if action == "create":
|
||||||
result = provider.create_task(provider_settings, dict(request_payload))
|
result = provider.create_task(provider_settings, dict(request_payload))
|
||||||
@@ -623,16 +578,14 @@ async def _emit_sync_event(
|
|||||||
"task_event": event,
|
"task_event": event,
|
||||||
"provider": provider_name,
|
"provider": provider_name,
|
||||||
"status": status,
|
"status": status,
|
||||||
"payload": dict(result.payload or {}),
|
"payload": {
|
||||||
|
"action": action,
|
||||||
|
"provider_payload": dict(request_payload),
|
||||||
|
"result_payload": dict(result.payload or {}),
|
||||||
|
},
|
||||||
"error": str(result.error or ""),
|
"error": str(result.error or ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
codex_run.status = status
|
|
||||||
codex_run.result_payload = dict(result.payload or {})
|
|
||||||
codex_run.error = str(result.error or "")
|
|
||||||
await sync_to_async(codex_run.save)(
|
|
||||||
update_fields=["status", "result_payload", "error", "updated_at"]
|
|
||||||
)
|
|
||||||
if result.ok and result.external_key and not task.external_key:
|
if result.ok and result.external_key and not task.external_key:
|
||||||
task.external_key = str(result.external_key)
|
task.external_key = str(result.external_key)
|
||||||
await sync_to_async(task.save)(update_fields=["external_key"])
|
await sync_to_async(task.save)(update_fields=["external_key"])
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
from core.models import ExternalChatLink, PersonIdentifier
|
|
||||||
|
|
||||||
|
|
||||||
def channel_variants(service: str, channel: str) -> list[str]:
|
|
||||||
value = str(channel or "").strip()
|
|
||||||
if not value:
|
|
||||||
return []
|
|
||||||
variants = [value]
|
|
||||||
service_key = str(service or "").strip().lower()
|
|
||||||
if service_key == "whatsapp":
|
|
||||||
bare = value.split("@", 1)[0].strip()
|
|
||||||
if bare and bare not in variants:
|
|
||||||
variants.append(bare)
|
|
||||||
direct = f"{bare}@s.whatsapp.net" if bare else ""
|
|
||||||
if direct and direct not in variants:
|
|
||||||
variants.append(direct)
|
|
||||||
group = f"{bare}@g.us" if bare else ""
|
|
||||||
if group and group not in variants:
|
|
||||||
variants.append(group)
|
|
||||||
if service_key == "signal":
|
|
||||||
digits = re.sub(r"[^0-9]", "", value)
|
|
||||||
if digits and digits not in variants:
|
|
||||||
variants.append(digits)
|
|
||||||
if digits:
|
|
||||||
plus = f"+{digits}"
|
|
||||||
if plus not in variants:
|
|
||||||
variants.append(plus)
|
|
||||||
return variants
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_external_chat_id(*, user, provider: str, service: str, channel: str) -> str:
|
|
||||||
variants = channel_variants(service, channel)
|
|
||||||
if not variants:
|
|
||||||
return ""
|
|
||||||
person_identifier = (
|
|
||||||
PersonIdentifier.objects.filter(
|
|
||||||
user=user,
|
|
||||||
service=service,
|
|
||||||
identifier__in=variants,
|
|
||||||
)
|
|
||||||
.select_related("person")
|
|
||||||
.order_by("-id")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if person_identifier is None:
|
|
||||||
return ""
|
|
||||||
link = (
|
|
||||||
ExternalChatLink.objects.filter(
|
|
||||||
user=user,
|
|
||||||
provider=provider,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
Q(person_identifier=person_identifier) | Q(person=person_identifier.person)
|
|
||||||
)
|
|
||||||
.order_by("-updated_at", "-id")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return str(getattr(link, "external_chat_id", "") or "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def compact_json_snippet(payload: Any, limit: int = 800) -> str:
|
|
||||||
text = str(payload or "").strip()
|
|
||||||
if len(text) <= limit:
|
|
||||||
return text
|
|
||||||
return text[:limit].rstrip() + "..."
|
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .base import TaskProvider
|
from .base import TaskProvider
|
||||||
from .claude_cli import ClaudeCLITaskProvider
|
|
||||||
from .codex_cli import CodexCLITaskProvider
|
|
||||||
from .mock import MockTaskProvider
|
from .mock import MockTaskProvider
|
||||||
|
|
||||||
PROVIDERS = {
|
PROVIDERS = {
|
||||||
"mock": MockTaskProvider(),
|
"mock": MockTaskProvider(),
|
||||||
"codex_cli": CodexCLITaskProvider(),
|
|
||||||
"claude_cli": ClaudeCLITaskProvider(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from hashlib import sha1
|
|
||||||
|
|
||||||
from .base import ProviderResult, TaskProvider
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCLITaskProvider(TaskProvider):
|
|
||||||
name = "claude_cli"
|
|
||||||
run_in_worker = True
|
|
||||||
|
|
||||||
def _timeout(self, config: dict) -> int:
|
|
||||||
try:
|
|
||||||
return max(1, int(config.get("timeout_seconds") or 60))
|
|
||||||
except Exception:
|
|
||||||
return 60
|
|
||||||
|
|
||||||
def _command(self, config: dict) -> str:
|
|
||||||
return str(config.get("command") or "claude").strip() or "claude"
|
|
||||||
|
|
||||||
def _workspace(self, config: dict) -> str:
|
|
||||||
return str(config.get("workspace_root") or "").strip()
|
|
||||||
|
|
||||||
def _profile(self, config: dict) -> str:
|
|
||||||
return str(config.get("default_profile") or "").strip()
|
|
||||||
|
|
||||||
def _is_task_sync_contract_mismatch(self, stderr: str) -> bool:
|
|
||||||
text = str(stderr or "").lower()
|
|
||||||
if "unexpected argument '--op'" in text:
|
|
||||||
return True
|
|
||||||
if "unexpected argument 'create'" in text and "usage: claude" in text:
|
|
||||||
return True
|
|
||||||
if "unexpected argument 'append_update'" in text and "usage: claude" in text:
|
|
||||||
return True
|
|
||||||
if "unexpected argument 'mark_complete'" in text and "usage: claude" in text:
|
|
||||||
return True
|
|
||||||
if "unexpected argument 'link_task'" in text and "usage: claude" in text:
|
|
||||||
return True
|
|
||||||
if "unrecognized subcommand 'create'" in text and "usage: claude" in text:
|
|
||||||
return True
|
|
||||||
if (
|
|
||||||
"unrecognized subcommand 'append_update'" in text
|
|
||||||
and "usage: claude" in text
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
if (
|
|
||||||
"unrecognized subcommand 'mark_complete'" in text
|
|
||||||
and "usage: claude" in text
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _builtin_stub_result(
|
|
||||||
self, op: str, payload: dict, stderr: str
|
|
||||||
) -> ProviderResult:
|
|
||||||
mode = str(payload.get("mode") or "default").strip().lower()
|
|
||||||
external_key = (
|
|
||||||
str(payload.get("external_key") or "").strip()
|
|
||||||
or str(payload.get("task_id") or "").strip()
|
|
||||||
)
|
|
||||||
if mode == "approval_response":
|
|
||||||
return ProviderResult(
|
|
||||||
ok=True,
|
|
||||||
external_key=external_key,
|
|
||||||
payload={
|
|
||||||
"op": op,
|
|
||||||
"status": "ok",
|
|
||||||
"summary": "approval acknowledged; resumed by builtin claude stub",
|
|
||||||
"requires_approval": False,
|
|
||||||
"output": "",
|
|
||||||
"fallback_mode": "builtin_task_sync_stub",
|
|
||||||
"fallback_reason": str(stderr or "")[:4000],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
task_id = str(payload.get("task_id") or "").strip()
|
|
||||||
key_basis = f"{op}:{task_id}:{payload.get('trigger_message_id') or payload.get('origin_message_id') or ''}"
|
|
||||||
approval_key = sha1(key_basis.encode("utf-8")).hexdigest()[:12]
|
|
||||||
summary = "Claude approval required (builtin stub fallback)"
|
|
||||||
return ProviderResult(
|
|
||||||
ok=True,
|
|
||||||
external_key=external_key,
|
|
||||||
payload={
|
|
||||||
"op": op,
|
|
||||||
"status": "requires_approval",
|
|
||||||
"requires_approval": True,
|
|
||||||
"summary": summary,
|
|
||||||
"approval_key": approval_key,
|
|
||||||
"permission_request": {
|
|
||||||
"summary": summary,
|
|
||||||
"requested_permissions": ["workspace_write"],
|
|
||||||
},
|
|
||||||
"resume_payload": {
|
|
||||||
"task_id": task_id,
|
|
||||||
"op": op,
|
|
||||||
},
|
|
||||||
"fallback_mode": "builtin_task_sync_stub",
|
|
||||||
"fallback_reason": str(stderr or "")[:4000],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _run(self, config: dict, op: str, payload: dict) -> ProviderResult:
|
|
||||||
base_cmd = [self._command(config), "task-sync"]
|
|
||||||
workspace = self._workspace(config)
|
|
||||||
profile = self._profile(config)
|
|
||||||
command_timeout = self._timeout(config)
|
|
||||||
data = json.dumps(dict(payload or {}), separators=(",", ":"))
|
|
||||||
common_args: list[str] = []
|
|
||||||
if workspace:
|
|
||||||
common_args.extend(["--workspace", workspace])
|
|
||||||
if profile:
|
|
||||||
common_args.extend(["--profile", profile])
|
|
||||||
|
|
||||||
primary_cmd = [*base_cmd, "--op", str(op), *common_args, "--payload-json", data]
|
|
||||||
fallback_cmd = [*base_cmd, str(op), *common_args, "--payload-json", data]
|
|
||||||
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(
|
|
||||||
primary_cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=command_timeout,
|
|
||||||
check=False,
|
|
||||||
cwd=workspace if workspace else None,
|
|
||||||
)
|
|
||||||
stderr_probe = str(completed.stderr or "").lower()
|
|
||||||
if (
|
|
||||||
completed.returncode != 0
|
|
||||||
and "unexpected argument '--op'" in stderr_probe
|
|
||||||
):
|
|
||||||
completed = subprocess.run(
|
|
||||||
fallback_cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=command_timeout,
|
|
||||||
check=False,
|
|
||||||
cwd=workspace if workspace else None,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return ProviderResult(
|
|
||||||
ok=False,
|
|
||||||
error=f"claude_cli_timeout_{command_timeout}s",
|
|
||||||
payload={"op": op, "timeout_seconds": command_timeout},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return ProviderResult(
|
|
||||||
ok=False, error=f"claude_cli_exec_error:{exc}", payload={"op": op}
|
|
||||||
)
|
|
||||||
|
|
||||||
stdout = str(completed.stdout or "").strip()
|
|
||||||
stderr = str(completed.stderr or "").strip()
|
|
||||||
parsed = {}
|
|
||||||
if stdout:
|
|
||||||
try:
|
|
||||||
parsed = json.loads(stdout)
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
parsed = {"raw_stdout": stdout}
|
|
||||||
except Exception:
|
|
||||||
parsed = {"raw_stdout": stdout}
|
|
||||||
|
|
||||||
parsed_status = str(parsed.get("status") or "").strip().lower()
|
|
||||||
permission_request = parsed.get("permission_request")
|
|
||||||
requires_approval = bool(
|
|
||||||
parsed.get("requires_approval")
|
|
||||||
or parsed_status in {"requires_approval", "waiting_approval"}
|
|
||||||
or permission_request
|
|
||||||
)
|
|
||||||
|
|
||||||
ext = (
|
|
||||||
str(parsed.get("external_key") or "").strip()
|
|
||||||
or str(parsed.get("task_id") or "").strip()
|
|
||||||
or str(payload.get("external_key") or "").strip()
|
|
||||||
)
|
|
||||||
|
|
||||||
ok = completed.returncode == 0
|
|
||||||
out_payload = {
|
|
||||||
"op": op,
|
|
||||||
"returncode": int(completed.returncode),
|
|
||||||
"stdout": stdout[:4000],
|
|
||||||
"stderr": stderr[:4000],
|
|
||||||
"parsed_status": parsed_status,
|
|
||||||
"requires_approval": requires_approval,
|
|
||||||
}
|
|
||||||
out_payload.update(parsed)
|
|
||||||
if (not ok) and self._is_task_sync_contract_mismatch(stderr):
|
|
||||||
return self._builtin_stub_result(op, dict(payload or {}), stderr)
|
|
||||||
return ProviderResult(
|
|
||||||
ok=ok,
|
|
||||||
external_key=ext,
|
|
||||||
error=("" if ok else stderr[:4000]),
|
|
||||||
payload=out_payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
def healthcheck(self, config: dict) -> ProviderResult:
|
|
||||||
command = self._command(config)
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(
|
|
||||||
[command, "--version"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=max(1, min(20, self._timeout(config))),
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return ProviderResult(ok=False, error=f"claude_cli_unavailable:{exc}")
|
|
||||||
return ProviderResult(
|
|
||||||
ok=(completed.returncode == 0),
|
|
||||||
payload={
|
|
||||||
"returncode": int(completed.returncode),
|
|
||||||
"stdout": str(completed.stdout or "").strip()[:1000],
|
|
||||||
"stderr": str(completed.stderr or "").strip()[:1000],
|
|
||||||
},
|
|
||||||
error=(
|
|
||||||
""
|
|
||||||
if completed.returncode == 0
|
|
||||||
else str(completed.stderr or "").strip()[:1000]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_task(self, config: dict, payload: dict) -> ProviderResult:
|
|
||||||
return self._run(config, "create", payload)
|
|
||||||
|
|
||||||
def append_update(self, config: dict, payload: dict) -> ProviderResult:
|
|
||||||
return self._run(config, "append_update", payload)
|
|
||||||
|
|
||||||
def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
|
|
||||||
return self._run(config, "mark_complete", payload)
|
|
||||||
|
|
||||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
|
||||||
return self._run(config, "link_task", payload)
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from hashlib import sha1
|
|
||||||
|
|
||||||
from .base import ProviderResult, TaskProvider
|
|
||||||
|
|
||||||
|
|
||||||
class CodexCLITaskProvider(TaskProvider):
|
|
||||||
name = "codex_cli"
|
|
||||||
run_in_worker = True
|
|
||||||
|
|
||||||
def _timeout(self, config: dict) -> int:
|
|
||||||
try:
|
|
||||||
return max(1, int(config.get("timeout_seconds") or 60))
|
|
||||||
except Exception:
|
|
||||||
return 60
|
|
||||||
|
|
||||||
def _command(self, config: dict) -> str:
|
|
||||||
return str(config.get("command") or "codex").strip() or "codex"
|
|
||||||
|
|
||||||
def _workspace(self, config: dict) -> str:
|
|
||||||
return str(config.get("workspace_root") or "").strip()
|
|
||||||
|
|
||||||
def _profile(self, config: dict) -> str:
|
|
||||||
return str(config.get("default_profile") or "").strip()
|
|
||||||
|
|
||||||
def _is_task_sync_contract_mismatch(self, stderr: str) -> bool:
|
|
||||||
text = str(stderr or "").lower()
|
|
||||||
if "unexpected argument '--op'" in text:
|
|
||||||
return True
|
|
||||||
if "unexpected argument 'create'" in text and "usage: codex" in text:
|
|
||||||
return True
|
|
||||||
if "unexpected argument 'append_update'" in text and "usage: codex" in text:
|
|
||||||
return True
|
|
||||||
if "unexpected argument 'mark_complete'" in text and "usage: codex" in text:
|
|
||||||
return True
|
|
||||||
if "unexpected argument 'link_task'" in text and "usage: codex" in text:
|
|
||||||
return True
|
|
||||||
if "unrecognized subcommand 'create'" in text and "usage: codex" in text:
|
|
||||||
return True
|
|
||||||
if "unrecognized subcommand 'append_update'" in text and "usage: codex" in text:
|
|
||||||
return True
|
|
||||||
if "unrecognized subcommand 'mark_complete'" in text and "usage: codex" in text:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _builtin_stub_result(
|
|
||||||
self, op: str, payload: dict, stderr: str
|
|
||||||
) -> ProviderResult:
|
|
||||||
mode = str(payload.get("mode") or "default").strip().lower()
|
|
||||||
external_key = (
|
|
||||||
str(payload.get("external_key") or "").strip()
|
|
||||||
or str(payload.get("task_id") or "").strip()
|
|
||||||
)
|
|
||||||
if mode == "approval_response":
|
|
||||||
return ProviderResult(
|
|
||||||
ok=True,
|
|
||||||
external_key=external_key,
|
|
||||||
payload={
|
|
||||||
"op": op,
|
|
||||||
"status": "ok",
|
|
||||||
"summary": "approval acknowledged; resumed by builtin codex stub",
|
|
||||||
"requires_approval": False,
|
|
||||||
"output": "",
|
|
||||||
"fallback_mode": "builtin_task_sync_stub",
|
|
||||||
"fallback_reason": str(stderr or "")[:4000],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
task_id = str(payload.get("task_id") or "").strip()
|
|
||||||
key_basis = f"{op}:{task_id}:{payload.get('trigger_message_id') or payload.get('origin_message_id') or ''}"
|
|
||||||
approval_key = sha1(key_basis.encode("utf-8")).hexdigest()[:12]
|
|
||||||
summary = "Codex approval required (builtin stub fallback)"
|
|
||||||
return ProviderResult(
|
|
||||||
ok=True,
|
|
||||||
external_key=external_key,
|
|
||||||
payload={
|
|
||||||
"op": op,
|
|
||||||
"status": "requires_approval",
|
|
||||||
"requires_approval": True,
|
|
||||||
"summary": summary,
|
|
||||||
"approval_key": approval_key,
|
|
||||||
"permission_request": {
|
|
||||||
"summary": summary,
|
|
||||||
"requested_permissions": ["workspace_write"],
|
|
||||||
},
|
|
||||||
"resume_payload": {
|
|
||||||
"task_id": task_id,
|
|
||||||
"op": op,
|
|
||||||
},
|
|
||||||
"fallback_mode": "builtin_task_sync_stub",
|
|
||||||
"fallback_reason": str(stderr or "")[:4000],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _run(self, config: dict, op: str, payload: dict) -> ProviderResult:
|
|
||||||
base_cmd = [self._command(config), "task-sync"]
|
|
||||||
workspace = self._workspace(config)
|
|
||||||
profile = self._profile(config)
|
|
||||||
command_timeout = self._timeout(config)
|
|
||||||
data = json.dumps(dict(payload or {}), separators=(",", ":"))
|
|
||||||
common_args: list[str] = []
|
|
||||||
if workspace:
|
|
||||||
common_args.extend(["--workspace", workspace])
|
|
||||||
if profile:
|
|
||||||
common_args.extend(["--profile", profile])
|
|
||||||
|
|
||||||
primary_cmd = [*base_cmd, "--op", str(op), *common_args, "--payload-json", data]
|
|
||||||
fallback_cmd = [*base_cmd, str(op), *common_args, "--payload-json", data]
|
|
||||||
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(
|
|
||||||
primary_cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=command_timeout,
|
|
||||||
check=False,
|
|
||||||
cwd=workspace if workspace else None,
|
|
||||||
)
|
|
||||||
stderr_probe = str(completed.stderr or "").lower()
|
|
||||||
if (
|
|
||||||
completed.returncode != 0
|
|
||||||
and "unexpected argument '--op'" in stderr_probe
|
|
||||||
):
|
|
||||||
completed = subprocess.run(
|
|
||||||
fallback_cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=command_timeout,
|
|
||||||
check=False,
|
|
||||||
cwd=workspace if workspace else None,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return ProviderResult(
|
|
||||||
ok=False,
|
|
||||||
error=f"codex_cli_timeout_{command_timeout}s",
|
|
||||||
payload={"op": op, "timeout_seconds": command_timeout},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return ProviderResult(
|
|
||||||
ok=False, error=f"codex_cli_exec_error:{exc}", payload={"op": op}
|
|
||||||
)
|
|
||||||
|
|
||||||
stdout = str(completed.stdout or "").strip()
|
|
||||||
stderr = str(completed.stderr or "").strip()
|
|
||||||
parsed = {}
|
|
||||||
if stdout:
|
|
||||||
try:
|
|
||||||
parsed = json.loads(stdout)
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
parsed = {"raw_stdout": stdout}
|
|
||||||
except Exception:
|
|
||||||
parsed = {"raw_stdout": stdout}
|
|
||||||
|
|
||||||
parsed_status = str(parsed.get("status") or "").strip().lower()
|
|
||||||
permission_request = parsed.get("permission_request")
|
|
||||||
requires_approval = bool(
|
|
||||||
parsed.get("requires_approval")
|
|
||||||
or parsed_status in {"requires_approval", "waiting_approval"}
|
|
||||||
or permission_request
|
|
||||||
)
|
|
||||||
|
|
||||||
ext = (
|
|
||||||
str(parsed.get("external_key") or "").strip()
|
|
||||||
or str(parsed.get("task_id") or "").strip()
|
|
||||||
or str(payload.get("external_key") or "").strip()
|
|
||||||
)
|
|
||||||
|
|
||||||
ok = completed.returncode == 0
|
|
||||||
out_payload = {
|
|
||||||
"op": op,
|
|
||||||
"returncode": int(completed.returncode),
|
|
||||||
"stdout": stdout[:4000],
|
|
||||||
"stderr": stderr[:4000],
|
|
||||||
"parsed_status": parsed_status,
|
|
||||||
"requires_approval": requires_approval,
|
|
||||||
}
|
|
||||||
out_payload.update(parsed)
|
|
||||||
if (not ok) and self._is_task_sync_contract_mismatch(stderr):
|
|
||||||
return self._builtin_stub_result(op, dict(payload or {}), stderr)
|
|
||||||
return ProviderResult(
|
|
||||||
ok=ok,
|
|
||||||
external_key=ext,
|
|
||||||
error=("" if ok else stderr[:4000]),
|
|
||||||
payload=out_payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
def healthcheck(self, config: dict) -> ProviderResult:
|
|
||||||
command = self._command(config)
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(
|
|
||||||
[command, "--version"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=max(1, min(20, self._timeout(config))),
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return ProviderResult(ok=False, error=f"codex_cli_unavailable:{exc}")
|
|
||||||
return ProviderResult(
|
|
||||||
ok=(completed.returncode == 0),
|
|
||||||
payload={
|
|
||||||
"returncode": int(completed.returncode),
|
|
||||||
"stdout": str(completed.stdout or "").strip()[:1000],
|
|
||||||
"stderr": str(completed.stderr or "").strip()[:1000],
|
|
||||||
},
|
|
||||||
error=(
|
|
||||||
""
|
|
||||||
if completed.returncode == 0
|
|
||||||
else str(completed.stderr or "").strip()[:1000]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_task(self, config: dict, payload: dict) -> ProviderResult:
|
|
||||||
return self._run(config, "create", payload)
|
|
||||||
|
|
||||||
def append_update(self, config: dict, payload: dict) -> ProviderResult:
|
|
||||||
return self._run(config, "append_update", payload)
|
|
||||||
|
|
||||||
def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
|
|
||||||
return self._run(config, "mark_complete", payload)
|
|
||||||
|
|
||||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
|
||||||
return self._run(config, "link_task", payload)
|
|
||||||
@@ -4,17 +4,29 @@
|
|||||||
{% load accessibility %}
|
{% load accessibility %}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en-GB">
|
<html lang="en-GB" class="{% block html_class %}{% endblock %}">
|
||||||
<head>
|
<head>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var storedTheme = null;
|
var storedTheme = null;
|
||||||
|
var resolvedTheme = null;
|
||||||
try {
|
try {
|
||||||
storedTheme = localStorage.getItem("theme");
|
storedTheme = localStorage.getItem("theme");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
}
|
}
|
||||||
if (storedTheme === "dark" || storedTheme === "light") {
|
if (storedTheme === "dark" || storedTheme === "light") {
|
||||||
document.documentElement.dataset.theme = storedTheme;
|
resolvedTheme = storedTheme;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
resolvedTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolvedTheme === "dark" || resolvedTheme === "light") {
|
||||||
|
document.documentElement.dataset.theme = resolvedTheme;
|
||||||
|
document.documentElement.style.colorScheme = resolvedTheme;
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.removeAttribute("data-theme");
|
document.documentElement.removeAttribute("data-theme");
|
||||||
}
|
}
|
||||||
@@ -30,23 +42,11 @@
|
|||||||
<link rel="preload" href="{% static 'vendor/fontawesome/webfonts/fa-solid-900.woff2' %}" as="font" type="font/woff2" integrity="sha512-Ph1xTLhfMycYSW+wUN8oL3Ggl56nGIS95EHiKWggcL/GbMNjPdib1Hreb1D4COlMxdiGCkk43nspQnpDuTjgQg==" crossorigin="anonymous">
|
<link rel="preload" href="{% static 'vendor/fontawesome/webfonts/fa-solid-900.woff2' %}" as="font" type="font/woff2" integrity="sha512-Ph1xTLhfMycYSW+wUN8oL3Ggl56nGIS95EHiKWggcL/GbMNjPdib1Hreb1D4COlMxdiGCkk43nspQnpDuTjgQg==" crossorigin="anonymous">
|
||||||
<link rel="preload" href="{% static 'vendor/fontawesome/webfonts/fa-regular-400.woff2' %}" as="font" type="font/woff2" integrity="sha512-qioT43fXB5q4Bbpn8sPQE9OIZLjKD0c0lVmpm6KmT8k34LM6gkRcOOMi1BOl2lohFG/7p9tzKfTP5G563BQq1g==" crossorigin="anonymous">
|
<link rel="preload" href="{% static 'vendor/fontawesome/webfonts/fa-regular-400.woff2' %}" as="font" type="font/woff2" integrity="sha512-qioT43fXB5q4Bbpn8sPQE9OIZLjKD0c0lVmpm6KmT8k34LM6gkRcOOMi1BOl2lohFG/7p9tzKfTP5G563BQq1g==" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}" integrity="sha512-yh2RE0wZCVZeysGiqTwDTO/dKelCbS9bP2L94UvOFtl/FKXcNAje3Y2oBg/ZMZ3LS1sicYk4dYVGtDex75fvvA==" crossorigin="anonymous">
|
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}" integrity="sha512-yh2RE0wZCVZeysGiqTwDTO/dKelCbS9bP2L94UvOFtl/FKXcNAje3Y2oBg/ZMZ3LS1sicYk4dYVGtDex75fvvA==" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="{% static 'css/bulma-tooltip.min.css' %}" integrity="sha512-SNDNIUvSYhnqDV9FFXaH/e0xZ6NzkG4Qm5dafLLf0PCMkzICKaOmMTgI3y2t2jZK+hAtP6A7UBcFqjWMhsujIg==" crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="{% static 'vendor/fontawesome/css/all.css' %}" integrity="sha512-UKBBxJ5N3/MYiSsYTlEsARsp4vELKVRIklED4Mb6wpuVFOgy5Blt+sXUdz1TDReqWsm64xxBA2QoBJRCxI0x5Q==" crossorigin="anonymous">
|
<link rel="stylesheet" href="{% static 'vendor/fontawesome/css/all.css' %}" integrity="sha512-UKBBxJ5N3/MYiSsYTlEsARsp4vELKVRIklED4Mb6wpuVFOgy5Blt+sXUdz1TDReqWsm64xxBA2QoBJRCxI0x5Q==" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="{% static 'css/bulma-slider.min.css' %}" integrity="sha512-9o5SkCRCA9thttRH3Gb5QXLxKdRiuRLdO6ToEPwRHGLXjrhTZwFj0rEHjrCcJvDN9/aNaWMpGOIEA2vZsHmEqw==" crossorigin="anonymous">
|
<link rel="stylesheet" href="{% static 'css/gia-theme.css' %}" integrity="sha512-ySzeXoQreOo29Fv+kBiggvr2yLJEj4LO+Srcdw6rAENl1cAl6fzjVITZld1grkwMb+Hxe1jczo623coGQr0jsw==" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="{% static 'css/bulma-calendar.min.css' %}" integrity="sha512-IOnJQkgQpezPDPTJcRiWD7YVI3sF2RYzYDl4isbDT2geSaEHRQ615UN/8GhJbSkvqkKRZu8SBCQ7XwKMqsqLFQ==" crossorigin="anonymous">
|
{% block extra_head_assets %}{% endblock %}
|
||||||
<link rel="stylesheet" href="{% static 'css/bulma-tagsinput.min.css' %}" integrity="sha512-NWTkcDRubZ3pyXbZZLQBILuVsRFs8c6QGgnfe4dm5/d6yp50U+xdoCDLIcSo51fFy/GXH0O2Oed1Z1sF1faxDA==" crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="{% static 'css/bulma-switch.min.css' %}" integrity="sha512-zjrHYubQoNgDVqVKTyGjKcvIeQlduZTvXCvcBwQ0iqJYKLKiz9cuFAN7e98zfKqCTpI/EgFRBRcTwJw20yAFuw==" crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="{% static 'css/gridstack.min.css' %}" integrity="sha512-ttQfsDTO64bamkJHeLDf0kzMP1NKfkootudPWS2V8Pwy+9z1wexSYjIT6/HXGg/bmtD+DRwsUnQoYEB0yePjbw==" crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="{% static 'css/gia-theme.css' %}" integrity="sha512-6BpiCie64b2f+fUAgX2EWY95FVBcGsSSnSJchQPwV5zFOR/A4sklIPC4HNXcT/NyOAY7PpNol6efClbh/NSGIA==" crossorigin="anonymous">
|
|
||||||
<script src="{% static 'js/bulma-calendar.min.js' %}" integrity="sha512-kkEtEtypXzruevjkoxhyEkqkZBtlhK7s8zt7IV2yPabgBwy5xbKL9uWeCS37ldS9AaNTSnveWTu4ivUvGMJUWA==" crossorigin="anonymous"></script>
|
|
||||||
<script src="{% static 'js/bulma-slider.min.js' %}" integrity="sha512-WLKXHCsMXTSIPsmQShJRE6K4IzwvNkhwxr/Oo8N3z+kzjhGleHibspmWLTawNMdl2z9E23XK20+yvUTDZ+zeNQ==" crossorigin="anonymous"></script>
|
|
||||||
<script src="{% static 'js/htmx.min.js' %}" integrity="sha512-CGXFnDNv5q48ciFeIyWFcfZhqYW0sSBiPO+HZDO3XLM+p8xjhezz5CCxtkXVDKfCbvF+iUhel7xoeSp19o7x7g==" crossorigin="anonymous"></script>
|
<script src="{% static 'js/htmx.min.js' %}" integrity="sha512-CGXFnDNv5q48ciFeIyWFcfZhqYW0sSBiPO+HZDO3XLM+p8xjhezz5CCxtkXVDKfCbvF+iUhel7xoeSp19o7x7g==" crossorigin="anonymous"></script>
|
||||||
<script defer src="{% static 'js/remove-me.js' %}" integrity="sha512-uhE4kDw2+tXdJPDKSttOEYhVnwYq310+d5DMQnTjafJ58QLlYPyx0RTCNbjcrTiGfCjAhaQob4AumEUa2m3TaQ==" crossorigin="anonymous"></script>
|
<script defer src="{% static 'js/remove-me.js' %}" integrity="sha512-uhE4kDw2+tXdJPDKSttOEYhVnwYq310+d5DMQnTjafJ58QLlYPyx0RTCNbjcrTiGfCjAhaQob4AumEUa2m3TaQ==" crossorigin="anonymous"></script>
|
||||||
<script defer src="{% static 'js/hyperscript.min.js' %}" integrity="sha512-l43sZzpnAddmYhJyfPrgv46XhJvA95gsA28/+eW4XZLSekQ8wlP68i9f22KGkRjY0HNiZrLc5MXGo4z/tM2QNA==" crossorigin="anonymous"></script>
|
|
||||||
<script src="{% static 'js/bulma-tagsinput.min.js' %}" integrity="sha512-Je6J++MjmmpxF30JCmRwM2KiK3uWQBQtqiNCjwzEMJKExLaa0BqerlYNa/fJAl5Rra4hMgRZF2fzg+V2vjE4Kw==" crossorigin="anonymous"></script>
|
|
||||||
<script src="{% static 'js/jquery.min.js' %}" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous"></script>
|
|
||||||
<script src="{% static 'js/gridstack-all.js' %}" integrity="sha512-djBPxwvBhDep1SvOhliatweHMORhVO3HabrfBjaW6nYsa7UcJYHty31x42m4HBSJXcJSQdoEgRPLVYGGIuIaDQ==" crossorigin="anonymous"></script>
|
|
||||||
<script defer src="{% static 'js/magnet.min.js' %}" integrity="sha512-aoQ3V4iCM8zTcdMDSUTRG1K9wqZzmDSisuaCLQexk9DdFy92oWvTUoAfCVLnGzzJClst8PmtasZg219REwyNkw==" crossorigin="anonymous"></script>
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var FAVICON_VERSION = "sq3";
|
var FAVICON_VERSION = "sq3";
|
||||||
@@ -110,6 +110,7 @@
|
|||||||
function applyTheme(mode, shouldPersist) {
|
function applyTheme(mode, shouldPersist) {
|
||||||
var validMode = mode === THEME_DARK ? THEME_DARK : THEME_LIGHT;
|
var validMode = mode === THEME_DARK ? THEME_DARK : THEME_LIGHT;
|
||||||
root.dataset.theme = validMode;
|
root.dataset.theme = validMode;
|
||||||
|
root.style.colorScheme = validMode;
|
||||||
applyFavicon();
|
applyFavicon();
|
||||||
updateToggleUI(validMode);
|
updateToggleUI(validMode);
|
||||||
if (shouldPersist !== false) {
|
if (shouldPersist !== false) {
|
||||||
@@ -162,6 +163,39 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener("htmx:afterRequest", function (event) {
|
||||||
|
const detail = (event && event.detail) || null;
|
||||||
|
const source = detail && detail.elt ? detail.elt : null;
|
||||||
|
if (!detail || !detail.successful || !source || !source.dataset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queueAfter = String(source.dataset.queueAfter || "");
|
||||||
|
if (queueAfter === "remove-card") {
|
||||||
|
const cardId = String(source.dataset.queueCardId || "").trim();
|
||||||
|
const refreshEvent = String(source.dataset.queueRefreshEvent || "").trim();
|
||||||
|
if (cardId) {
|
||||||
|
const card = document.getElementById(cardId);
|
||||||
|
if (card) {
|
||||||
|
card.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (refreshEvent) {
|
||||||
|
htmx.trigger(document.body, refreshEvent);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (queueAfter === "show-inline-editor") {
|
||||||
|
const editorId = String(source.dataset.queueEditorId || "").trim();
|
||||||
|
if (!editorId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const editor = document.getElementById(editorId);
|
||||||
|
if (editor) {
|
||||||
|
editor.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
var composeLink = document.getElementById("nav-compose-link");
|
var composeLink = document.getElementById("nav-compose-link");
|
||||||
var composeDropdown = document.getElementById("nav-compose-contacts");
|
var composeDropdown = document.getElementById("nav-compose-contacts");
|
||||||
var composePreviewLoaded = false;
|
var composePreviewLoaded = false;
|
||||||
@@ -201,14 +235,14 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
{% get_accessibility_settings request.user as a11y_settings %}
|
{% get_accessibility_settings request.user as a11y_settings %}
|
||||||
<body{% if a11y_settings and a11y_settings.disable_animations %} class="reduced-motion"{% endif %}>
|
<body class="{% if a11y_settings and a11y_settings.disable_animations %}reduced-motion {% endif %}{% block body_class %}{% endblock %}">
|
||||||
|
|
||||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||||
<div class="navbar-brand">
|
<div class="navbar-brand">
|
||||||
<div class="navbar-item">
|
<div class="navbar-item">
|
||||||
<span class="gia-brand-shell">
|
<span class="gia-brand-shell">
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<button class="button is-light theme-toggle-button gia-brand-logo brand-theme-toggle js-theme-toggle" type="button" data-theme-mode="light" aria-label="Theme toggle">
|
<button class="gia-brand-logo brand-theme-toggle js-theme-toggle" type="button" data-theme-mode="light" aria-label="Theme toggle">
|
||||||
<svg class="brand-theme-logo" viewBox="0 0 213.35 150.85" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
<svg class="brand-theme-logo" viewBox="0 0 213.35 150.85" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
||||||
<g transform="translate(38.831 -7.4316)">
|
<g transform="translate(38.831 -7.4316)">
|
||||||
<g transform="matrix(.99287 0 0 .99911 1.2367 -30.308)">
|
<g transform="matrix(.99287 0 0 .99911 1.2367 -30.308)">
|
||||||
@@ -291,7 +325,7 @@
|
|||||||
Services
|
Services
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown is-right gia-navbar-dropdown">
|
||||||
<a class="navbar-item" href="{% url 'signal' %}">
|
<a class="navbar-item" href="{% url 'signal' %}">
|
||||||
Signal
|
Signal
|
||||||
</a>
|
</a>
|
||||||
@@ -309,7 +343,7 @@
|
|||||||
Data
|
Data
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown is-right gia-navbar-dropdown">
|
||||||
<a class="navbar-item" href="{% url 'sessions' type='page' %}">
|
<a class="navbar-item" href="{% url 'sessions' type='page' %}">
|
||||||
Sessions
|
Sessions
|
||||||
</a>
|
</a>
|
||||||
@@ -324,7 +358,7 @@
|
|||||||
Settings
|
Settings
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown is-right gia-navbar-dropdown">
|
||||||
<div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
|
<div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
|
||||||
General
|
General
|
||||||
</div>
|
</div>
|
||||||
@@ -391,28 +425,29 @@
|
|||||||
<a class="navbar-item{% if request.resolver_match.url_name == 'osint_workspace' %} is-current-route{% endif %}" href="{% url 'osint_workspace' %}">
|
<a class="navbar-item{% if request.resolver_match.url_name == 'osint_workspace' %} is-current-route{% endif %}" href="{% url 'osint_workspace' %}">
|
||||||
OSINT Workspace
|
OSINT Workspace
|
||||||
</a>
|
</a>
|
||||||
|
<hr class="navbar-divider">
|
||||||
|
<a class="navbar-item" href="{% url 'logout' %}">
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
<button class="navbar-item button is-light add-button" type="button" style="display:none;">
|
||||||
|
Install App
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not user.is_authenticated %}
|
||||||
<div class="navbar-item">
|
<div class="navbar-item">
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
{% if not user.is_authenticated %}
|
|
||||||
<a class="button is-info" href="{% url 'signup' %}">
|
<a class="button is-info" href="{% url 'signup' %}">
|
||||||
<strong>Sign up</strong>
|
<strong>Sign up</strong>
|
||||||
</a>
|
</a>
|
||||||
<a class="button" href="{% url 'two_factor:login' %}">
|
<a class="button" href="{% url 'two_factor:login' %}">
|
||||||
Log in
|
Log in
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if user.is_authenticated %}
|
|
||||||
<button class="button is-light add-button" type="button" style="display:none;">Install App</button>
|
|
||||||
<a class="button is-dark" href="{% url 'logout' %}">Logout</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -449,218 +484,26 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.giaPrepareWidgetTarget = function () {
|
|
||||||
const target = document.getElementById("widgets-here");
|
|
||||||
if (target) {
|
|
||||||
target.style.display = "block";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.giaCanSpawnWidgets = function () {
|
|
||||||
return !!(
|
|
||||||
window.grid &&
|
|
||||||
typeof window.grid.addWidget === "function" &&
|
|
||||||
document.getElementById("grid-stack-main") &&
|
|
||||||
document.getElementById("widgets-here")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.giaEnableWidgetSpawnButtons = function (root) {
|
|
||||||
const scope = root && root.querySelectorAll ? root : document;
|
|
||||||
const canSpawn = window.giaCanSpawnWidgets();
|
|
||||||
scope.querySelectorAll(".js-widget-spawn-trigger").forEach(function (button) {
|
|
||||||
const widgetUrl = String(
|
|
||||||
button.getAttribute("data-widget-url")
|
|
||||||
|| button.getAttribute("hx-get")
|
|
||||||
|| ""
|
|
||||||
).trim();
|
|
||||||
const visible = canSpawn && !!widgetUrl;
|
|
||||||
button.classList.toggle("is-hidden", !visible);
|
|
||||||
button.setAttribute("aria-hidden", visible ? "false" : "true");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
window.giaPrepareWindowAnchor = function (trigger) {
|
|
||||||
if (!trigger || !trigger.getBoundingClientRect) {
|
|
||||||
window.giaWindowAnchor = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rect = trigger.getBoundingClientRect();
|
|
||||||
window.giaWindowAnchor = {
|
|
||||||
left: rect.left,
|
|
||||||
right: rect.right,
|
|
||||||
top: rect.top,
|
|
||||||
bottom: rect.bottom,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
ts: Date.now(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
window.giaPositionFloatingWindow = function (windowEl) {
|
|
||||||
if (!windowEl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
|
||||||
const margin = 12;
|
|
||||||
const rect = windowEl.getBoundingClientRect();
|
|
||||||
const anchor = window.giaWindowAnchor || null;
|
|
||||||
windowEl.style.position = "fixed";
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
const centeredLeftViewport = Math.max(
|
|
||||||
margin,
|
|
||||||
Math.round((window.innerWidth - rect.width) / 2)
|
|
||||||
);
|
|
||||||
const centeredTopViewport = Math.max(
|
|
||||||
margin,
|
|
||||||
Math.round((window.innerHeight - rect.height) / 2)
|
|
||||||
);
|
|
||||||
windowEl.style.left = centeredLeftViewport + "px";
|
|
||||||
windowEl.style.top = centeredTopViewport + "px";
|
|
||||||
windowEl.style.right = "auto";
|
|
||||||
windowEl.style.bottom = "auto";
|
|
||||||
windowEl.style.transform = "none";
|
|
||||||
windowEl.setAttribute("tabindex", "-1");
|
|
||||||
if (typeof windowEl.focus === "function") {
|
|
||||||
windowEl.focus({preventScroll: true});
|
|
||||||
}
|
|
||||||
if (typeof windowEl.scrollIntoView === "function") {
|
|
||||||
windowEl.scrollIntoView({block: "center", inline: "center", behavior: "smooth"});
|
|
||||||
}
|
|
||||||
window.giaWindowAnchor = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!anchor || (Date.now() - anchor.ts) > 10000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const desiredLeftViewport = anchor.left;
|
|
||||||
const desiredTopViewport = anchor.bottom + 6;
|
|
||||||
const maxLeftViewport = window.innerWidth - rect.width - margin;
|
|
||||||
const maxTopViewport = window.innerHeight - rect.height - margin;
|
|
||||||
const boundedLeftViewport = Math.max(
|
|
||||||
margin,
|
|
||||||
Math.min(desiredLeftViewport, maxLeftViewport)
|
|
||||||
);
|
|
||||||
const boundedTopViewport = Math.max(
|
|
||||||
margin,
|
|
||||||
Math.min(desiredTopViewport, maxTopViewport)
|
|
||||||
);
|
|
||||||
windowEl.style.left = boundedLeftViewport + "px";
|
|
||||||
windowEl.style.top = boundedTopViewport + "px";
|
|
||||||
windowEl.style.right = "auto";
|
|
||||||
windowEl.style.bottom = "auto";
|
|
||||||
windowEl.style.transform = "none";
|
|
||||||
window.giaWindowAnchor = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.giaEnableFloatingWindowInteractions = function (windowEl) {
|
|
||||||
if (!windowEl || windowEl.dataset.giaWindowInteractive === "1") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
windowEl.dataset.giaWindowInteractive = "1";
|
|
||||||
|
|
||||||
// Disable magnet-block global drag so text inputs remain editable.
|
|
||||||
windowEl.setAttribute("unmovable", "");
|
|
||||||
|
|
||||||
const heading = windowEl.querySelector(".panel-heading");
|
|
||||||
if (!heading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dragging = false;
|
|
||||||
let startX = 0;
|
|
||||||
let startY = 0;
|
|
||||||
let startLeft = 0;
|
|
||||||
let startTop = 0;
|
|
||||||
|
|
||||||
const onMove = function (event) {
|
|
||||||
if (!dragging) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const deltaX = event.clientX - startX;
|
|
||||||
const deltaY = event.clientY - startY;
|
|
||||||
windowEl.style.left = (startLeft + deltaX) + "px";
|
|
||||||
windowEl.style.top = (startTop + deltaY) + "px";
|
|
||||||
windowEl.style.right = "auto";
|
|
||||||
windowEl.style.bottom = "auto";
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopDrag = function () {
|
|
||||||
dragging = false;
|
|
||||||
document.removeEventListener("pointermove", onMove);
|
|
||||||
document.removeEventListener("pointerup", stopDrag);
|
|
||||||
};
|
|
||||||
|
|
||||||
heading.addEventListener("pointerdown", function (event) {
|
|
||||||
if (event.button !== 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const interactive = event.target.closest(
|
|
||||||
"button, a, input, textarea, select, label, .delete, .icon"
|
|
||||||
);
|
|
||||||
if (interactive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rect = windowEl.getBoundingClientRect();
|
|
||||||
windowEl.style.position = "fixed";
|
|
||||||
startLeft = rect.left;
|
|
||||||
startTop = rect.top;
|
|
||||||
startX = event.clientX;
|
|
||||||
startY = event.clientY;
|
|
||||||
dragging = true;
|
|
||||||
document.addEventListener("pointermove", onMove);
|
|
||||||
document.addEventListener("pointerup", stopDrag);
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("click", function (event) {
|
|
||||||
const trigger = event.target.closest(".js-widget-spawn-trigger");
|
|
||||||
if (!trigger) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.giaPrepareWidgetTarget();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
window.giaEnableWidgetSpawnButtons(document);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.addEventListener("htmx:afterSwap", function (event) {
|
|
||||||
const target = (event && event.target) || document;
|
|
||||||
window.giaEnableWidgetSpawnButtons(target);
|
|
||||||
const targetId = (target && target.id) || "";
|
|
||||||
if (targetId === "windows-here") {
|
|
||||||
const floatingWindows = target.querySelectorAll(".floating-window");
|
|
||||||
floatingWindows.forEach(function (floatingWindow) {
|
|
||||||
window.setTimeout(function () {
|
|
||||||
window.giaPositionFloatingWindow(floatingWindow);
|
|
||||||
window.giaEnableFloatingWindowInteractions(floatingWindow);
|
|
||||||
}, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% block outer_content %}
|
{% block outer_content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<div>
|
{% block standard_page_shell %}
|
||||||
|
<div class="gia-standard-page-shell">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% include "partials/settings-hierarchy-nav.html" %}
|
{% include "partials/settings-hierarchy-nav.html" %}
|
||||||
{% block content_wrapper %}
|
{% block content_wrapper %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
<div id="modals-here">
|
<div id="modals-here">
|
||||||
</div>
|
</div>
|
||||||
<div id="windows-here" style="z-index: 120;">
|
<div id="windows-here" style="z-index: 120;">
|
||||||
</div>
|
</div>
|
||||||
<div id="widgets-here" style="display: none;">
|
<div id="widgets-here" style="display: none;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,90 +1,56 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load joinsep %}
|
{% load joinsep %}
|
||||||
|
{% block html_class %}gia-has-workspace-root{% endblock %}
|
||||||
|
{% block body_class %}gia-has-workspace{% endblock %}
|
||||||
|
{% block extra_head_assets %}
|
||||||
|
{{ block.super }}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/gridstack.min.css' %}" integrity="sha512-ttQfsDTO64bamkJHeLDf0kzMP1NKfkootudPWS2V8Pwy+9z1wexSYjIT6/HXGg/bmtD+DRwsUnQoYEB0yePjbw==" crossorigin="anonymous">
|
||||||
|
<script defer src="{% static 'js/gridstack-all.js' %}" integrity="sha512-djBPxwvBhDep1SvOhliatweHMORhVO3HabrfBjaW6nYsa7UcJYHty31x42m4HBSJXcJSQdoEgRPLVYGGIuIaDQ==" crossorigin="anonymous"></script>
|
||||||
|
<script defer src="{% static 'js/workspace-shell.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
{% block standard_page_shell %}{% endblock %}
|
||||||
{% block outer_content %}
|
{% block outer_content %}
|
||||||
|
<section class="section gia-workspace-page">
|
||||||
<div class="grid-stack" id="grid-stack-main">
|
<div class="gia-workspace-shell">
|
||||||
|
<div class="gia-workspace-main">
|
||||||
|
<div class="gia-workspace-grid-column">
|
||||||
|
<div class="grid-stack gia-workspace-grid" id="grid-stack-main">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<aside
|
||||||
var grid = GridStack.init({
|
id="gia-snap-assistant"
|
||||||
cellHeight: 20,
|
class="panel gia-snap-assistant is-hidden"
|
||||||
cellWidth: 45,
|
aria-label="Snap assistant">
|
||||||
cellHeightUnit: 'px',
|
<p class="panel-heading gia-snap-assistant-heading">
|
||||||
auto: true,
|
<span class="icon is-small"><i class="fa-solid fa-table-columns"></i></span>
|
||||||
float: true,
|
<span>Snap Right</span>
|
||||||
draggable: {handle: '.panel-heading', scroll: false, appendTo: 'body'},
|
<button
|
||||||
removable: false,
|
type="button"
|
||||||
animate: true,
|
class="delete is-small js-gia-snap-assistant-close"
|
||||||
});
|
aria-label="Close snap assistant"></button>
|
||||||
// GridStack.init();
|
</p>
|
||||||
|
<div class="panel-block">
|
||||||
// a widget is ready to be loaded
|
<div class="content is-small">
|
||||||
document.addEventListener('load-widget', function(event) {
|
<p>Choose a second window for the right side.</p>
|
||||||
let containers = htmx.findAll('#widget');
|
</div>
|
||||||
for (let x = 0, len = containers.length; x < len; x++) {
|
</div>
|
||||||
container = containers[x];
|
<div class="panel-block is-active gia-snap-assistant-body">
|
||||||
// get the scripts, they won't be run on the new element so we need to eval them
|
<div
|
||||||
let widgetelement = container.firstElementChild.cloneNode(true);
|
id="gia-snap-assistant-options"
|
||||||
console.log(widgetelement);
|
class="buttons are-small gia-snap-assistant-options"></div>
|
||||||
var scripts = htmx.findAll(widgetelement, "script");
|
</div>
|
||||||
var new_id = widgetelement.id;
|
</aside>
|
||||||
|
</div>
|
||||||
// check if there's an existing element like the one we want to swap
|
<nav
|
||||||
let grid_element = htmx.find('#grid-stack-main');
|
id="gia-taskbar"
|
||||||
let existing_widget = htmx.find(grid_element, "#"+new_id);
|
class="tabs is-boxed is-small gia-taskbar is-hidden"
|
||||||
|
aria-label="Open windows">
|
||||||
// get the size and position attributes
|
<ul id="gia-taskbar-items">
|
||||||
if (existing_widget) {
|
</ul>
|
||||||
let attrs = existing_widget.getAttributeNames();
|
</nav>
|
||||||
for (let i = 0, len = attrs.length; i < len; i++) {
|
</div>
|
||||||
if (attrs[i].startsWith('gs-')) { // only target gridstack attributes
|
<div id="gia-workspace-stash" class="is-hidden" aria-hidden="true"></div>
|
||||||
widgetelement.setAttribute(attrs[i], existing_widget.getAttribute(attrs[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// clear the queue element
|
|
||||||
container.outerHTML = "";
|
|
||||||
// container.firstElementChild.outerHTML = "";
|
|
||||||
grid.addWidget(widgetelement);
|
|
||||||
|
|
||||||
// re-create the HTMX JS listeners, otherwise HTMX won't work inside the grid
|
|
||||||
htmx.process(widgetelement);
|
|
||||||
if (typeof window.giaEnableWidgetSpawnButtons === "function") {
|
|
||||||
window.giaEnableWidgetSpawnButtons(widgetelement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// update the size of the widget according to its content
|
|
||||||
var added_widget = htmx.find(grid_element, "#"+new_id);
|
|
||||||
var itemContent = htmx.find(added_widget, ".control");
|
|
||||||
var scrollheight = itemContent.scrollHeight+80;
|
|
||||||
var verticalmargin = 0;
|
|
||||||
var cellheight = grid.opts.cellHeight;
|
|
||||||
var height = Math.ceil((scrollheight + verticalmargin) / (cellheight + verticalmargin));
|
|
||||||
var opts = {
|
|
||||||
h: height,
|
|
||||||
}
|
|
||||||
grid.update(
|
|
||||||
added_widget,
|
|
||||||
opts
|
|
||||||
);
|
|
||||||
|
|
||||||
// run the JS scripts inside the added element again
|
|
||||||
for (var i = 0; i < scripts.length; i++) {
|
|
||||||
eval(scripts[i].innerHTML);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// clear the containers we just added
|
|
||||||
// for (let x = 0, len = containers.length; x < len; x++) {
|
|
||||||
// container = containers[x];
|
|
||||||
// container.inner = "";
|
|
||||||
// }
|
|
||||||
grid.compact();
|
|
||||||
if (typeof window.giaEnableWidgetSpawnButtons === "function") {
|
|
||||||
window.giaEnableWidgetSpawnButtons(document);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<div>
|
<div>
|
||||||
{% block load_widgets %}
|
{% block load_widgets %}
|
||||||
<!-- <div
|
<!-- <div
|
||||||
@@ -92,9 +58,10 @@
|
|||||||
hx-get="#"
|
hx-get="#"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="afterend"
|
hx-swap="beforeend"
|
||||||
style="display: none;"></div> -->
|
style="display: none;"></div> -->
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,23 +1,62 @@
|
|||||||
<div id="widget">
|
<div
|
||||||
|
class="js-gia-widget-shell"
|
||||||
|
data-gia-widget-shell="1"
|
||||||
|
{% if widget_style_hrefs %}data-gia-style-hrefs="{{ widget_style_hrefs|join:'|' }}"{% endif %}
|
||||||
|
{% if widget_script_srcs %}data-gia-script-srcs="{{ widget_script_srcs|join:'|' }}"{% endif %}>
|
||||||
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
|
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
|
||||||
<div class="grid-stack-item-content">
|
<div class="grid-stack-item-content">
|
||||||
<nav class="panel">
|
<nav class="panel gia-widget-panel">
|
||||||
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
|
<p class="panel-heading gia-widget-heading">
|
||||||
{% block close_button %}
|
<span class="gia-widget-heading-main">
|
||||||
{% include "mixins/partials/close-widget.html" %}
|
<span class="icon is-small gia-widget-heading-icon">
|
||||||
{% endblock %}
|
|
||||||
<span class="icon is-small mr-1">
|
|
||||||
<i class="{{ widget_icon|default:'fa-solid fa-window-maximize' }}"></i>
|
<i class="{{ widget_icon|default:'fa-solid fa-window-maximize' }}"></i>
|
||||||
</span>
|
</span>
|
||||||
<i
|
<span class="gia-widget-title">
|
||||||
class="fa-solid fa-arrows-minimize has-text-grey-light float-right"
|
|
||||||
onclick="grid.compact();"></i>
|
|
||||||
{% block heading %}
|
{% block heading %}
|
||||||
{{ title }}
|
{{ title }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="buttons are-small has-addons gia-widget-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-light js-gia-widget-action"
|
||||||
|
data-gia-action="tile"
|
||||||
|
data-gia-widget-id="widget-{{ unique }}"
|
||||||
|
aria-label="Tile window">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-border-all"></i></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-light js-gia-widget-action"
|
||||||
|
data-gia-action="snap-left"
|
||||||
|
data-gia-widget-id="widget-{{ unique }}"
|
||||||
|
aria-label="Snap window left">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-light js-gia-widget-action"
|
||||||
|
data-gia-action="snap-right"
|
||||||
|
data-gia-widget-id="widget-{{ unique }}"
|
||||||
|
aria-label="Snap window right">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-arrow-right"></i></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-light js-gia-widget-action"
|
||||||
|
data-gia-action="minimize"
|
||||||
|
data-gia-widget-id="widget-{{ unique }}"
|
||||||
|
aria-label="Minimize window">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-window-minimize"></i></span>
|
||||||
|
</button>
|
||||||
|
{% block close_button %}
|
||||||
|
{% include "mixins/partials/close-widget.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<article class="panel-block is-active">
|
<article class="panel-block is-active gia-widget-body">
|
||||||
<div class="control">
|
<div class="control gia-widget-control{% if widget_control_class %} {{ widget_control_class }}{% endif %}">
|
||||||
{% block panel_content %}
|
{% block panel_content %}
|
||||||
{% include window_content %}
|
{% include window_content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -27,12 +66,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
{% block custom_script %}
|
|
||||||
{% endblock %}
|
|
||||||
var widget_event = new Event("load-widget");
|
|
||||||
document.dispatchEvent(widget_event);
|
|
||||||
</script>
|
|
||||||
{% block custom_end %}
|
{% block custom_end %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
<div class="level">
|
<div class="level">
|
||||||
<div class="level-left">
|
<div class="level-left">
|
||||||
<div class="level-item">
|
<div class="level-item">
|
||||||
@@ -227,6 +229,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
document.querySelectorAll(".trace-run-expand").forEach(function (button) {
|
document.querySelectorAll(".trace-run-expand").forEach(function (button) {
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
{% extends "index.html" %}
|
{% extends "index.html" %}
|
||||||
|
|
||||||
{% block load_widgets %}
|
{% block load_widgets %}
|
||||||
<div
|
{% url 'ai_workspace_contacts' type='widget' as contacts_widget_url %}
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
{% include "partials/workspace-widget-loader.html" with widget_url=contacts_widget_url %}
|
||||||
hx-get="{% url 'ai_workspace_contacts' type='widget' %}"
|
|
||||||
hx-target="#widgets-here"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="afterend"
|
|
||||||
style="display: none;"></div>
|
|
||||||
{% if selected_person_id %}
|
{% if selected_person_id %}
|
||||||
<div
|
{% url 'ai_workspace_person' type='widget' person_id=selected_person_id as person_widget_url %}
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
{% include "partials/workspace-widget-loader.html" with widget_url=person_widget_url trigger_delay="250ms" %}
|
||||||
hx-get="{% url 'ai_workspace_person' type='widget' person_id=selected_person_id %}"
|
|
||||||
hx-target="#widgets-here"
|
|
||||||
hx-trigger="load delay:250ms"
|
|
||||||
hx-swap="afterend"
|
|
||||||
style="display: none;"></div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<section class="section">
|
|
||||||
<div class="container gia-page-shell">
|
|
||||||
<div class="gia-page-header">
|
|
||||||
<div>
|
|
||||||
<h1 class="title is-4">Codex Status</h1>
|
|
||||||
<p class="subtitle is-6">Worker-backed task sync status, runs, and approvals for the canonical GIA task store.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article class="box">
|
|
||||||
<div class="codex-inline-stats">
|
|
||||||
<span><strong>Provider</strong> codex_cli</span>
|
|
||||||
<span><strong>Health</strong> <span class="{% if health and health.ok %}has-text-success{% else %}has-text-danger{% endif %}">{% if health and health.ok %}online{% else %}offline{% endif %}</span></span>
|
|
||||||
<span><strong>Pending</strong> {{ queue_counts.pending }}</span>
|
|
||||||
<span><strong>Waiting Approval</strong> {{ queue_counts.waiting_approval }}</span>
|
|
||||||
</div>
|
|
||||||
{% if health and health.error %}
|
|
||||||
<p class="help">Healthcheck error: <code>{{ health.error }}</code></p>
|
|
||||||
{% endif %}
|
|
||||||
<p class="help">Config snapshot: command=<code>{{ provider_settings.command }}</code>, workspace=<code>{{ provider_settings.workspace_root|default:"-" }}</code>, profile=<code>{{ provider_settings.default_profile|default:"-" }}</code>, instance=<code>{{ provider_settings.instance_label }}</code>, approver=<code>{{ provider_settings.approver_service }} {{ provider_settings.approver_identifier }}</code>.</p>
|
|
||||||
<p class="help"><a href="{% url 'tasks_settings' %}">Edit in Task Automation</a>.</p>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Run Filters</h2>
|
|
||||||
<form method="get">
|
|
||||||
<div class="columns is-multiline">
|
|
||||||
<div class="column is-2">
|
|
||||||
<label class="label is-size-7">Status</label>
|
|
||||||
<input class="input is-small" name="status" value="{{ filters.status }}" placeholder="ok/failed/...">
|
|
||||||
</div>
|
|
||||||
<div class="column is-2">
|
|
||||||
<label class="label is-size-7">Service</label>
|
|
||||||
<input class="input is-small" name="service" value="{{ filters.service }}" placeholder="signal">
|
|
||||||
</div>
|
|
||||||
<div class="column is-3">
|
|
||||||
<label class="label is-size-7">Channel</label>
|
|
||||||
<input class="input is-small" name="channel" value="{{ filters.channel }}" placeholder="identifier">
|
|
||||||
</div>
|
|
||||||
<div class="column is-3">
|
|
||||||
<label class="label is-size-7">Project</label>
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="project">
|
|
||||||
<option value="">All</option>
|
|
||||||
{% for row in projects %}
|
|
||||||
<option value="{{ row.id }}" {% if filters.project == row.id|stringformat:"s" %}selected{% endif %}>{{ row.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-2">
|
|
||||||
<label class="label is-size-7">Date From</label>
|
|
||||||
<input class="input is-small" type="date" name="date_from" value="{{ filters.date_from }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="button is-small is-link is-light" type="submit">Apply</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Runs</h2>
|
|
||||||
<table class="table is-fullwidth is-size-7 is-striped">
|
|
||||||
<thead><tr><th>When</th><th>Status</th><th>Service/Channel</th><th>Project</th><th>Task</th><th>Summary</th><th>Files</th><th>Links</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for run in runs %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ run.created_at }}</td>
|
|
||||||
<td>{{ run.status }}</td>
|
|
||||||
<td>{{ run.source_service }} · <code>{{ run.source_channel }}</code></td>
|
|
||||||
<td>{{ run.project.name|default:"-" }}</td>
|
|
||||||
<td>{% if run.task %}<a href="{% url 'tasks_task' task_id=run.task.id %}">#{{ run.task.reference_code }}</a>{% else %}-{% endif %}</td>
|
|
||||||
<td>{{ run.result_payload.summary|default:"-" }}</td>
|
|
||||||
<td>{{ run.result_payload.files_modified_count|default:"0" }}</td>
|
|
||||||
<td>
|
|
||||||
<details>
|
|
||||||
<summary>Details</summary>
|
|
||||||
<p><strong>Request</strong></p>
|
|
||||||
<pre>{{ run.request_payload }}</pre>
|
|
||||||
<p><strong>Result</strong></p>
|
|
||||||
<pre>{{ run.result_payload }}</pre>
|
|
||||||
<p><strong>Error</strong> {{ run.error|default:"-" }}</p>
|
|
||||||
</details>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="8">No runs.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Approvals Queue</h2>
|
|
||||||
<table class="table is-fullwidth is-size-7 is-striped">
|
|
||||||
<thead><tr><th>Requested</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Permissions</th><th>Run</th><th>Task</th><th>Actions</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in permission_requests %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.requested_at }}</td>
|
|
||||||
<td><code>{{ row.approval_key }}</code></td>
|
|
||||||
<td>{{ row.status }}</td>
|
|
||||||
<td>{{ row.summary|default:"-" }}</td>
|
|
||||||
<td><pre>{{ row.requested_permissions }}</pre></td>
|
|
||||||
<td><code>{{ row.codex_run_id }}</code></td>
|
|
||||||
<td>{% if row.codex_run.task %}<a href="{% url 'tasks_task' task_id=row.codex_run.task.id %}">#{{ row.codex_run.task.reference_code }}</a>{% else %}-{% endif %}</td>
|
|
||||||
<td>
|
|
||||||
{% if row.status == 'pending' %}
|
|
||||||
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="request_id" value="{{ row.id }}">
|
|
||||||
<input type="hidden" name="decision" value="approve">
|
|
||||||
<button class="button is-small is-success is-light" type="submit">Approve</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="request_id" value="{{ row.id }}">
|
|
||||||
<input type="hidden" name="decision" value="deny">
|
|
||||||
<button class="button is-small is-danger is-light" type="submit">Deny</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="8">No permission requests.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<style>
|
|
||||||
.codex-inline-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.95rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.codex-inline-stats span {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -463,16 +463,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const applyDefaults = function () {
|
const applyDefaults = function () {
|
||||||
const slug = String(commandSelect.value || "").trim().toLowerCase();
|
|
||||||
if (slug === "codex") {
|
|
||||||
triggerInput.value = ".codex";
|
|
||||||
if (!nameInput.value || nameInput.value === "Business Plan") {
|
|
||||||
nameInput.value = "Codex";
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
triggerInput.value = ".bp";
|
triggerInput.value = ".bp";
|
||||||
if (!nameInput.value || nameInput.value === "Codex") {
|
if (!nameInput.value) {
|
||||||
nameInput.value = "Business Plan";
|
nameInput.value = "Business Plan";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
{% extends "index.html" %}
|
{% extends "index.html" %}
|
||||||
|
|
||||||
{% block load_widgets %}
|
{% block load_widgets %}
|
||||||
<div
|
{% include "partials/workspace-widget-loader.html" with widget_url=contacts_widget_url %}
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
{% include "partials/workspace-widget-loader.html" with widget_url=history_widget_url trigger_delay="125ms" %}
|
||||||
hx-get="{{ contacts_widget_url }}"
|
|
||||||
hx-target="#widgets-here"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="afterend"
|
|
||||||
style="display: none;"></div>
|
|
||||||
{% if initial_widget_url %}
|
{% if initial_widget_url %}
|
||||||
<div
|
{% include "partials/workspace-widget-loader.html" with widget_url=initial_widget_url trigger_delay="250ms" %}
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
|
||||||
hx-get="{{ initial_widget_url }}"
|
|
||||||
hx-target="#widgets-here"
|
|
||||||
hx-trigger="load delay:250ms"
|
|
||||||
hx-swap="afterend"
|
|
||||||
style="display: none;"></div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block extra_head_assets %}
|
||||||
|
{% include "partials/compose-panel-assets.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section pt-5 pb-0">
|
<section class="section pt-5 pb-0">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
{% extends "index.html" %}
|
{% extends "index.html" %}
|
||||||
|
|
||||||
{% block load_widgets %}
|
{% block load_widgets %}
|
||||||
<div
|
{% include "partials/workspace-widget-loader.html" with widget_url=tabs_widget_url %}
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
|
||||||
hx-get="{{ tabs_widget_url }}"
|
|
||||||
hx-target="#widgets-here"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="afterend"
|
|
||||||
style="display: none;"></div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
{% extends "index.html" %}
|
{% extends "index.html" %}
|
||||||
|
|
||||||
{% block load_widgets %}
|
{% block load_widgets %}
|
||||||
<div
|
{% url accounts_url_name type='widget' as accounts_widget_url %}
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
{% include "partials/workspace-widget-loader.html" with widget_url=accounts_widget_url %}
|
||||||
hx-get="{% url accounts_url_name type='widget' %}"
|
|
||||||
hx-target="#widgets-here"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="afterend"
|
|
||||||
style="display: none;"></div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,12 +15,6 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
|
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
|
||||||
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="task_id" value="{{ task.id }}">
|
|
||||||
<input type="hidden" name="next" value="{% url 'tasks_task' task_id=task.id %}">
|
|
||||||
<button class="button is-small is-link is-light" type="submit">Send to Codex</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<article class="box">
|
<article class="box">
|
||||||
<h2 class="title is-6">Events</h2>
|
<h2 class="title is-6">Events</h2>
|
||||||
@@ -57,57 +51,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</article>
|
</article>
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">External Sync</h2>
|
|
||||||
<table class="table is-fullwidth is-size-7">
|
|
||||||
<thead><tr><th>When</th><th>Provider</th><th>Status</th><th>Error</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in sync_events %}
|
|
||||||
<tr><td>{{ row.updated_at }}</td><td>{{ row.provider }}</td><td>{{ row.status }}</td><td>{{ row.error }}</td></tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="4">No sync events.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</article>
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Codex Runs</h2>
|
|
||||||
<table class="table is-fullwidth is-size-7">
|
|
||||||
<thead><tr><th>When</th><th>Status</th><th>Summary</th><th>Files</th><th>Error</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in codex_runs %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.updated_at }}</td>
|
|
||||||
<td>{{ row.status }}</td>
|
|
||||||
<td>{{ row.result_payload.summary|default:"-" }}</td>
|
|
||||||
<td>{{ row.result_payload.files_modified_count|default:"0" }}</td>
|
|
||||||
<td>{{ row.error|default:"" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="5">No Codex runs.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</article>
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Permission Requests</h2>
|
|
||||||
<table class="table is-fullwidth is-size-7">
|
|
||||||
<thead><tr><th>When</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Resolved</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in permission_requests %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.requested_at }}</td>
|
|
||||||
<td><code>{{ row.approval_key }}</code></td>
|
|
||||||
<td>{{ row.status }}</td>
|
|
||||||
<td>{{ row.summary|default:"-" }}</td>
|
|
||||||
<td>{{ row.resolved_at|default:"-" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="5">No permission requests.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</article>
|
|
||||||
</div></section>
|
</div></section>
|
||||||
<style>
|
<style>
|
||||||
.task-event-payload {
|
.task-event-payload {
|
||||||
|
|||||||
@@ -216,37 +216,6 @@
|
|||||||
<td>{{ row.status_snapshot }}</td>
|
<td>{{ row.status_snapshot }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
|
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
|
||||||
{% if enabled_providers|length == 1 %}
|
|
||||||
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="task_id" value="{{ row.id }}">
|
|
||||||
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
|
|
||||||
<input type="hidden" name="provider" value="{{ enabled_providers.0 }}">
|
|
||||||
<button class="button is-small is-link is-light" type="submit">
|
|
||||||
Send to {% if enabled_providers.0 == "claude_cli" %}Claude{% else %}Codex{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% elif enabled_providers %}
|
|
||||||
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="task_id" value="{{ row.id }}">
|
|
||||||
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
|
|
||||||
<div class="field has-addons" style="display:inline-flex;">
|
|
||||||
<div class="control">
|
|
||||||
<div class="select is-small">
|
|
||||||
<select name="provider">
|
|
||||||
{% for p in enabled_providers %}
|
|
||||||
<option value="{{ p }}">{% if p == "claude_cli" %}Claude{% else %}Codex{% endif %}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<button class="button is-small is-link is-light" type="submit">Send</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -322,7 +322,7 @@
|
|||||||
<div class="column is-6">
|
<div class="column is-6">
|
||||||
<section class="tasks-panel">
|
<section class="tasks-panel">
|
||||||
<h3 class="title is-7">Providers</h3>
|
<h3 class="title is-7">Providers</h3>
|
||||||
<p class="help">Controls outbound sync to external tracking systems. If disabled, tasks are still derived and visible inside GIA only.</p>
|
<p class="help">Controls outbound sync from canonical GIA tasks. If disabled, tasks still work inside GIA but no sync event is emitted.</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="provider_update">
|
<input type="hidden" name="action" value="provider_update">
|
||||||
@@ -333,250 +333,9 @@
|
|||||||
<button class="button is-small is-link is-light" type="submit">Save</button>
|
<button class="button is-small is-link is-light" type="submit">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<hr>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="provider_update">
|
|
||||||
<input type="hidden" name="provider" value="codex_cli">
|
|
||||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if codex_provider_config and codex_provider_config.enabled %}checked{% endif %}> Enable Codex CLI provider</label>
|
|
||||||
<p class="help">Codex task-sync runs in a dedicated worker (<code>python manage.py codex_worker</code>).</p>
|
|
||||||
<p class="help">This provider config is global per-user and shared across all projects/chats. This phase is task-sync only (no full transcript mirroring by default).</p>
|
|
||||||
<div class="field" style="margin-top:0.5rem;">
|
|
||||||
<label class="label is-size-7">Command</label>
|
|
||||||
<input class="input is-small" name="command" value="{{ codex_provider_settings.command }}" placeholder="codex">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Workspace Root</label>
|
|
||||||
<input class="input is-small" name="workspace_root" value="{{ codex_provider_settings.workspace_root }}" placeholder="/code/xf">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Default Profile</label>
|
|
||||||
<input class="input is-small" name="default_profile" value="{{ codex_provider_settings.default_profile }}" placeholder="default">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Timeout Seconds</label>
|
|
||||||
<input class="input is-small" type="number" min="1" name="timeout_seconds" value="{{ codex_provider_settings.timeout_seconds }}">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Instance Label</label>
|
|
||||||
<input class="input is-small" name="instance_label" value="{{ codex_provider_settings.instance_label }}" placeholder="default">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Approver Service</label>
|
|
||||||
<input class="input is-small" name="approver_service" value="{{ codex_provider_settings.approver_service }}" placeholder="signal">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Approver Identifier</label>
|
|
||||||
<input class="input is-small" name="approver_identifier" value="{{ codex_provider_settings.approver_identifier }}" placeholder="+15550000001">
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:0.5rem;">
|
|
||||||
<button class="button is-small is-link is-light" type="submit">Save Codex Provider</button>
|
|
||||||
<a class="button is-small is-light" href="{% url 'codex_settings' %}">Open Codex Status</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<hr>
|
|
||||||
<article class="box" style="margin-top:0.5rem;">
|
|
||||||
<h4 class="title is-7">Codex Compact Summary</h4>
|
|
||||||
<p class="help">
|
|
||||||
Health:
|
|
||||||
{% if codex_compact_summary.healthcheck_ok %}
|
|
||||||
<span class="tag is-success is-light">online</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="tag is-danger is-light">offline</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if codex_compact_summary.healthcheck_error %}
|
|
||||||
<code>{{ codex_compact_summary.healthcheck_error }}</code>
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<p class="help">
|
|
||||||
Worker heartbeat:
|
|
||||||
{% if codex_compact_summary.worker_heartbeat_at %}
|
|
||||||
{{ codex_compact_summary.worker_heartbeat_at }} ({{ codex_compact_summary.worker_heartbeat_age }})
|
|
||||||
{% else %}
|
|
||||||
no worker activity yet
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<div class="tags">
|
|
||||||
<span class="tag is-light">pending {{ codex_compact_summary.queue_counts.pending }}</span>
|
|
||||||
<span class="tag is-warning is-light">waiting_approval {{ codex_compact_summary.queue_counts.waiting_approval }}</span>
|
|
||||||
<span class="tag is-danger is-light">failed {{ codex_compact_summary.queue_counts.failed }}</span>
|
|
||||||
<span class="tag is-success is-light">ok {{ codex_compact_summary.queue_counts.ok }}</span>
|
|
||||||
</div>
|
|
||||||
<table class="table is-fullwidth is-size-7 is-striped" style="margin-top:0.5rem;">
|
|
||||||
<thead><tr><th>When</th><th>Status</th><th>Task</th><th>Summary</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for run in codex_compact_summary.recent_runs %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ run.created_at }}</td>
|
|
||||||
<td>{{ run.status }}</td>
|
|
||||||
<td>{% if run.task %}<a href="{% url 'tasks_task' task_id=run.task.id %}">#{{ run.task.reference_code }}</a>{% else %}-{% endif %}</td>
|
|
||||||
<td>{{ run.result_payload.summary|default:"-" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="4">No runs yet.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</article>
|
|
||||||
<hr>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="provider_update">
|
|
||||||
<input type="hidden" name="provider" value="claude_cli">
|
|
||||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if claude_provider_config and claude_provider_config.enabled %}checked{% endif %}> Enable Claude CLI provider</label>
|
|
||||||
<p class="help">Claude task-sync runs in the same dedicated worker (<code>python manage.py codex_worker</code>).</p>
|
|
||||||
<p class="help">This provider config is global per-user and shared across all projects/chats.</p>
|
|
||||||
<div class="field" style="margin-top:0.5rem;">
|
|
||||||
<label class="label is-size-7">Command</label>
|
|
||||||
<input class="input is-small" name="command" value="{{ claude_provider_settings.command }}" placeholder="claude">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Workspace Root</label>
|
|
||||||
<input class="input is-small" name="workspace_root" value="{{ claude_provider_settings.workspace_root }}" placeholder="/code/xf">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Default Profile</label>
|
|
||||||
<input class="input is-small" name="default_profile" value="{{ claude_provider_settings.default_profile }}" placeholder="default">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Timeout Seconds</label>
|
|
||||||
<input class="input is-small" type="number" min="1" name="timeout_seconds" value="{{ claude_provider_settings.timeout_seconds }}">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Approver Service</label>
|
|
||||||
<input class="input is-small" name="approver_service" value="{{ claude_provider_settings.approver_service }}" placeholder="signal">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Approver Identifier</label>
|
|
||||||
<input class="input is-small" name="approver_identifier" value="{{ claude_provider_settings.approver_identifier }}" placeholder="+15550000001">
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:0.5rem;">
|
|
||||||
<button class="button is-small is-link is-light" type="submit">Save Claude Provider</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<hr>
|
|
||||||
<article class="box" style="margin-top:0.5rem;">
|
|
||||||
<h4 class="title is-7">Claude Compact Summary</h4>
|
|
||||||
<p class="help">
|
|
||||||
Health:
|
|
||||||
{% if claude_compact_summary.healthcheck_ok %}
|
|
||||||
<span class="tag is-success is-light">online</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="tag is-danger is-light">offline</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if claude_compact_summary.healthcheck_error %}
|
|
||||||
<code>{{ claude_compact_summary.healthcheck_error }}</code>
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<div class="tags">
|
|
||||||
<span class="tag is-light">pending {{ claude_compact_summary.queue_counts.pending }}</span>
|
|
||||||
<span class="tag is-warning is-light">waiting_approval {{ claude_compact_summary.queue_counts.waiting_approval }}</span>
|
|
||||||
<span class="tag is-danger is-light">failed {{ claude_compact_summary.queue_counts.failed }}</span>
|
|
||||||
<span class="tag is-success is-light">ok {{ claude_compact_summary.queue_counts.ok }}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Task Inbox</a>.</p>
|
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Task Inbox</a>.</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-12">
|
|
||||||
<section class="tasks-panel">
|
|
||||||
<h3 class="title is-7">External Chat Links</h3>
|
|
||||||
<p class="help">Map one GIA contact to one Codex thread for task-sync routing.</p>
|
|
||||||
<details class="tasks-external-help">
|
|
||||||
<summary class="is-size-7">More info</summary>
|
|
||||||
<p class="help">
|
|
||||||
This is task-sync only. It does not mirror full chat history. The link tells the Codex worker which Codex conversation/session should receive updates for tasks from that contact/group.
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
{% if external_link_scoped %}
|
|
||||||
<article class="message is-info is-light tasks-link-scope-note">
|
|
||||||
<div class="message-body">
|
|
||||||
Scoped to <strong>{{ external_link_scope_label }}</strong>. Only matching identifiers are available below.
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" class="block">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="external_chat_link_upsert">
|
|
||||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
|
||||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<div class="columns is-multiline is-variable is-2 tasks-external-link-columns">
|
|
||||||
<div class="column is-12-mobile is-4-tablet is-2-desktop">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Provider</label>
|
|
||||||
<div class="control">
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="provider">
|
|
||||||
<option value="codex_cli" selected>codex_cli</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-12-mobile is-8-tablet is-5-desktop">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Contact</label>
|
|
||||||
<div class="control">
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="person_identifier_id">
|
|
||||||
<option value="">Unlinked</option>
|
|
||||||
{% for row in external_link_person_identifiers %}
|
|
||||||
<option value="{{ row.id }}">{{ row.person.name }} · {{ row.service }} · {{ row.identifier }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="help">Which GIA contact/group this link belongs to.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-12-mobile is-8-tablet is-3-desktop">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Codex Chat ID</label>
|
|
||||||
<div class="control">
|
|
||||||
<input class="input is-small" name="external_chat_id" placeholder="codex-chat-...">
|
|
||||||
</div>
|
|
||||||
<p class="help">Stable Codex conversation/session ID.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-6-mobile is-4-tablet is-2-desktop">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Enabled</label>
|
|
||||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" checked> Active</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<div class="control">
|
|
||||||
<button class="button is-small is-link is-light" type="submit">Save Link</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<table class="table is-fullwidth is-striped is-size-7">
|
|
||||||
<thead><tr><th>Provider</th><th>Person</th><th>Identifier</th><th>External Chat</th><th>Enabled</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in external_chat_links %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.provider }}</td>
|
|
||||||
<td>{% if row.person %}{{ row.person.name }}{% else %}-{% endif %}</td>
|
|
||||||
<td>{% if row.person_identifier %}{{ row.person_identifier.service }} · {{ row.person_identifier.identifier }}{% else %}-{% endif %}</td>
|
|
||||||
<td>{{ row.external_chat_id }}</td>
|
|
||||||
<td>{{ row.enabled }}</td>
|
|
||||||
<td>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="external_chat_link_delete">
|
|
||||||
<input type="hidden" name="external_link_id" value="{{ row.id }}">
|
|
||||||
<button class="button is-danger is-light is-small" type="submit">Delete</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="6">No external chat links.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -607,20 +366,6 @@
|
|||||||
.tasks-settings-page .tasks-settings-list {
|
.tasks-settings-page .tasks-settings-list {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
.tasks-settings-page .tasks-link-scope-note {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
.tasks-settings-page .tasks-external-help {
|
|
||||||
margin-bottom: 0.55rem;
|
|
||||||
}
|
|
||||||
.tasks-settings-page .tasks-external-help > summary {
|
|
||||||
cursor: pointer;
|
|
||||||
color: #4a4a4a;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
}
|
|
||||||
.tasks-settings-page .tasks-external-link-columns .field {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.tasks-settings-page .prefix-chip {
|
.tasks-settings-page .prefix-chip {
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<div
|
<div
|
||||||
id="ai-result-links-{{ person.id }}-{{ operation }}-{{ ai_result_id|default:'new' }}"
|
id="ai-result-links-{{ person.id }}-{{ operation }}-{{ ai_result_id|default:'new' }}"
|
||||||
style="margin-bottom: 0.5rem;">
|
style="margin-bottom: 0.5rem;">
|
||||||
<div class="tags has-addons" style="display: inline-flex; margin-bottom: 0.4rem;">
|
<div class="tags has-addons gia-tag-ribbon" style="margin-bottom: 0.4rem;">
|
||||||
<span class="tag is-dark">
|
<span class="tag is-dark gia-badge">
|
||||||
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
|
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="tag is-white" style="border: 1px solid rgba(0, 0, 0, 0.2);">
|
<span class="tag is-white gia-badge gia-tag-ribbon-main">
|
||||||
AI {{ operation_label }}
|
AI {{ operation_label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,13 +85,28 @@
|
|||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-get="{{ compose_widget_url }}"
|
hx-get="{{ compose_widget_url }}"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
hx-swap="afterend"
|
hx-swap="beforeend"
|
||||||
title="Open Manual Text widget here"
|
title="Open Manual Text widget here"
|
||||||
aria-label="Open Manual Text widget here">
|
aria-label="Open Manual Text widget here">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
|
||||||
<span>Widget</span>
|
<span>Widget</span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if history_widget_url %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-light is-small js-widget-spawn-trigger is-hidden"
|
||||||
|
data-widget-url="{{ history_widget_url }}"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{{ history_widget_url }}"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
title="Open message history widget here"
|
||||||
|
aria-label="Open message history widget here">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-clock-rotate-left"></i></span>
|
||||||
|
<span>History</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,12 +28,12 @@
|
|||||||
hx-get="{% url 'ai_workspace_person' type='widget' person_id=row.person.id %}"
|
hx-get="{% url 'ai_workspace_person' type='widget' person_id=row.person.id %}"
|
||||||
hx-include="#ai-window-form"
|
hx-include="#ai-window-form"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
hx-swap="afterend">
|
hx-swap="beforeend">
|
||||||
<span class="tags has-addons" style="display: inline-flex; width: 100%; margin: 0; white-space: nowrap;">
|
<span class="tags has-addons gia-tag-ribbon">
|
||||||
<span class="tag is-dark" style="min-width: 2.5rem; justify-content: center;">
|
<span class="tag is-dark gia-badge" style="min-width: 2.5rem; justify-content: center;">
|
||||||
<i class="fa-solid fa-comment-dots" aria-hidden="true"></i>
|
<i class="fa-solid fa-comment-dots" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="tag is-white" style="flex: 1; display: inline-flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding-left: 0.7rem; padding-right: 0.7rem; border-top: 1px solid rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(0, 0, 0, 0.2);">
|
<span class="tag is-white gia-badge gia-tag-ribbon-main">
|
||||||
<span style="display: inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0;">
|
<span style="display: inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0;">
|
||||||
<strong>{{ row.person.name }}</strong>
|
<strong>{{ row.person.name }}</strong>
|
||||||
</span>
|
</span>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<small style="padding-left: 0.5rem;">{{ row.last_ts_label }}</small>
|
<small style="padding-left: 0.5rem;">{{ row.last_ts_label }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="tag is-dark" style="min-width: 3.25rem; justify-content: center;">{{ row.message_count }}</span>
|
<span class="tag is-dark gia-badge" style="min-width: 3.25rem; justify-content: center;">{{ row.message_count }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -49,11 +49,11 @@
|
|||||||
class="button is-fullwidth"
|
class="button is-fullwidth"
|
||||||
style="border-radius: 8px; border: 0; background: transparent; box-shadow: none; padding: 0;"
|
style="border-radius: 8px; border: 0; background: transparent; box-shadow: none; padding: 0;"
|
||||||
disabled>
|
disabled>
|
||||||
<span class="tags has-addons" style="display: inline-flex; width: 100%; margin: 0; white-space: nowrap;">
|
<span class="tags has-addons gia-tag-ribbon">
|
||||||
<span class="tag is-info is-light" style="min-width: 2.5rem; justify-content: center;">
|
<span class="tag is-info is-light gia-badge" style="min-width: 2.5rem; justify-content: center;">
|
||||||
<i class="fa-solid fa-users" aria-hidden="true"></i>
|
<i class="fa-solid fa-users" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="tag is-white" style="flex: 1; display: inline-flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding-left: 0.7rem; padding-right: 0.7rem; border-top: 1px solid rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(0, 0, 0, 0.2);">
|
<span class="tag is-white gia-badge gia-tag-ribbon-main">
|
||||||
<span style="display: inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0;">
|
<span style="display: inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0;">
|
||||||
<strong>{{ row.chat_name }}</strong>
|
<strong>{{ row.chat_name }}</strong>
|
||||||
<small class="has-text-grey">{{ row.service }}</small>
|
<small class="has-text-grey">{{ row.service }}</small>
|
||||||
|
|||||||
24
core/templates/partials/bulma-send-composer.html
Normal file
24
core/templates/partials/bulma-send-composer.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<div class="box is-shadowless gia-send-composer{% if composer_class %} {{ composer_class }}{% endif %}">
|
||||||
|
<div class="field has-addons gia-send-composer-row">
|
||||||
|
<div class="control is-expanded gia-send-composer-input-wrap">
|
||||||
|
<textarea
|
||||||
|
id="{{ textarea_id }}"
|
||||||
|
class="textarea gia-send-composer-input{% if textarea_class %} {{ textarea_class }}{% endif %}"
|
||||||
|
name="{{ textarea_name|default:'text' }}"
|
||||||
|
rows="{{ textarea_rows|default:'1' }}"
|
||||||
|
{% if textarea_placeholder %}placeholder="{{ textarea_placeholder }}"{% endif %}></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="control gia-send-composer-action">
|
||||||
|
<button
|
||||||
|
class="button gia-send-composer-button{% if button_class %} {{ button_class }}{% endif %}"
|
||||||
|
type="{{ button_type|default:'submit' }}"
|
||||||
|
{% if button_disabled %}disabled{% endif %}
|
||||||
|
{% if button_title %}title="{{ button_title }}"{% endif %}>
|
||||||
|
{% if button_icon_class %}
|
||||||
|
<span class="icon is-small"><i class="{{ button_icon_class }}"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ button_label|default:"Send" }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
88
core/templates/partials/compose-message-row.html
Normal file
88
core/templates/partials/compose-message-row.html
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}{% if msg.is_deleted %} is-deleted{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}"{% if msg.reply_to_id %} data-reply-to-id="{{ msg.reply_to_id }}"{% endif %} data-reply-snippet="{{ msg.display_text|default:msg.text|default:''|truncatechars:120|escape }}">
|
||||||
|
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
||||||
|
{% if msg.reply_to_id %}
|
||||||
|
<div class="compose-reply-ref" data-reply-target-id="{{ msg.reply_to_id }}">
|
||||||
|
<button type="button" class="compose-reply-link" title="Jump to referenced message">
|
||||||
|
Reply to: {{ msg.reply_preview|default:"Referenced message"|escape }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="compose-source-badge-wrap">
|
||||||
|
<span class="tag is-light gia-badge compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span>
|
||||||
|
</div>
|
||||||
|
{% if msg.image_urls %}
|
||||||
|
{% for image_url in msg.image_urls %}
|
||||||
|
<figure class="compose-media">
|
||||||
|
<img
|
||||||
|
class="compose-image"
|
||||||
|
src="{{ image_url }}"
|
||||||
|
alt="Attachment"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async">
|
||||||
|
</figure>
|
||||||
|
{% endfor %}
|
||||||
|
{% elif msg.image_url %}
|
||||||
|
<figure class="compose-media">
|
||||||
|
<img
|
||||||
|
class="compose-image"
|
||||||
|
src="{{ msg.image_url }}"
|
||||||
|
alt="Attachment"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async">
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
{% if not msg.hide_text %}
|
||||||
|
<p class="compose-body">{{ msg.display_text|default:"(no text)" }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="compose-body compose-image-fallback is-hidden">(no text)</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if msg.edit_count %}
|
||||||
|
<details class="compose-edit-history">
|
||||||
|
<summary>Edited {{ msg.edit_count }} time{% if msg.edit_count != 1 %}s{% endif %}</summary>
|
||||||
|
<ul>
|
||||||
|
{% for edit in msg.edit_history %}
|
||||||
|
<li>
|
||||||
|
{% if edit.edited_display %}{{ edit.edited_display }}{% else %}Unknown time{% endif %}
|
||||||
|
{% if edit.actor %} · {{ edit.actor }}{% endif %}
|
||||||
|
{% if edit.source_service %} · {{ edit.source_service|upper }}{% endif %}
|
||||||
|
<div class="compose-edit-diff">
|
||||||
|
<span class="compose-edit-old">{{ edit.previous_text|default:"(empty)" }}</span>
|
||||||
|
<span class="compose-edit-arrow">→</span>
|
||||||
|
<span class="compose-edit-new">{{ edit.new_text|default:"(empty)" }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
{% if msg.reactions %}
|
||||||
|
<div class="compose-reactions" aria-label="Message reactions">
|
||||||
|
{% for reaction in msg.reactions %}
|
||||||
|
<span
|
||||||
|
class="tag is-light gia-badge compose-reaction-chip"
|
||||||
|
data-emoji="{{ reaction.emoji|escape }}"
|
||||||
|
data-actor="{{ reaction.actor|default:''|escape }}"
|
||||||
|
data-source-service="{{ reaction.source_service|default:''|escape }}"
|
||||||
|
title="{{ reaction.actor|default:'Unknown' }} via {{ reaction.source_service|default:'unknown'|upper }}">
|
||||||
|
{{ reaction.emoji }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="compose-msg-meta">
|
||||||
|
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
|
||||||
|
{% if msg.is_edited %}
|
||||||
|
<span class="tag is-light gia-badge compose-msg-flag is-edited" title="Message edited{% if msg.last_edit_display %} at {{ msg.last_edit_display }}{% endif %}">edited</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if msg.is_deleted %}
|
||||||
|
<span class="tag is-light gia-badge compose-msg-flag is-deleted" title="Deleted{% if msg.deleted_display %} at {{ msg.deleted_display }}{% endif %}{% if msg.deleted_actor %} by {{ msg.deleted_actor }}{% endif %}">deleted</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<button type="button" class="button is-white is-small compose-reply-btn" title="Reply to this message" aria-label="Reply to this message">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-reply"></i></span>
|
||||||
|
<span class="compose-reply-btn-label">Reply</span>
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
7
core/templates/partials/compose-message-rows.html
Normal file
7
core/templates/partials/compose-message-rows.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% if message_rows %}
|
||||||
|
{% for msg in message_rows %}
|
||||||
|
{% include "partials/compose-message-row.html" with msg=msg only %}
|
||||||
|
{% endfor %}
|
||||||
|
{% elif show_empty_state %}
|
||||||
|
<p class="compose-empty">{{ empty_message|default:"No stored messages for this contact yet." }}</p>
|
||||||
|
{% endif %}
|
||||||
6
core/templates/partials/compose-panel-assets.html
Normal file
6
core/templates/partials/compose-panel-assets.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{% load static %}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/compose-panel.css' %}">
|
||||||
|
<script defer src="{% static 'js/compose-panel-core.js' %}"></script>
|
||||||
|
<script defer src="{% static 'js/compose-panel-thread.js' %}"></script>
|
||||||
|
<script defer src="{% static 'js/compose-panel-send.js' %}"></script>
|
||||||
|
<script defer src="{% static 'js/compose-panel.js' %}"></script>
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
{% load static %}
|
|
||||||
<link rel="stylesheet" href="{% static 'css/compose-panel.css' %}">
|
|
||||||
<div
|
<div
|
||||||
id="{{ panel_id }}"
|
id="{{ panel_id }}"
|
||||||
class="compose-shell box"
|
class="compose-shell box"
|
||||||
@@ -10,12 +8,14 @@
|
|||||||
data-initial-typing="{{ typing_state_json|default:'{}'|escape }}"
|
data-initial-typing="{{ typing_state_json|default:'{}'|escape }}"
|
||||||
data-cancel-send-url="{% url 'compose_cancel_send' %}"
|
data-cancel-send-url="{% url 'compose_cancel_send' %}"
|
||||||
data-command-result-url="{% url 'compose_command_result' %}">
|
data-command-result-url="{% url 'compose_command_result' %}">
|
||||||
<div class="compose-shell-head is-flex is-justify-content-space-between is-align-items-flex-start is-flex-wrap-wrap is-gap-2 mb-3">
|
<div class="compose-shell-head is-flex is-justify-content-space-between is-align-items-flex-start is-flex-wrap-wrap is-gap-2 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<p class="compose-shell-eyebrow is-size-7 has-text-weight-semibold mb-1">Manual Text Mode</p>
|
<p class="compose-shell-eyebrow is-size-7 has-text-weight-semibold mb-1">Manual Text Mode</p>
|
||||||
|
<div class="compose-context-row">
|
||||||
|
<div class="compose-context-primary">
|
||||||
{% if recent_contacts %}
|
{% if recent_contacts %}
|
||||||
<div class="compose-contact-switch">
|
<div class="compose-contact-switch">
|
||||||
<div class="select is-small">
|
<div class="select is-small is-fullwidth">
|
||||||
<select id="{{ panel_id }}-contact-select" class="compose-contact-select">
|
<select id="{{ panel_id }}-contact-select" class="compose-contact-select">
|
||||||
{% for option in recent_contacts %}
|
{% for option in recent_contacts %}
|
||||||
<option
|
<option
|
||||||
@@ -41,12 +41,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p id="{{ panel_id }}-meta-line" class="is-size-7 compose-meta-line mb-0">
|
</div>
|
||||||
{{ service|title }} · {{ identifier }}
|
|
||||||
</p>
|
|
||||||
{% if platform_options %}
|
{% if platform_options %}
|
||||||
|
<div class="compose-context-secondary">
|
||||||
<div class="compose-platform-switch">
|
<div class="compose-platform-switch">
|
||||||
<div class="select is-small">
|
<div class="select is-small is-fullwidth">
|
||||||
<select id="{{ panel_id }}-platform-select" class="compose-platform-select">
|
<select id="{{ panel_id }}-platform-select" class="compose-platform-select">
|
||||||
{% for option in platform_options %}
|
{% for option in platform_options %}
|
||||||
<option
|
<option
|
||||||
@@ -60,8 +59,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<p id="{{ panel_id }}-meta-line" class="is-size-7 compose-meta-line mb-0">
|
||||||
|
{{ service|title }} · {{ identifier }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if signal_ingest_warning %}
|
{% if signal_ingest_warning %}
|
||||||
@@ -86,96 +90,7 @@
|
|||||||
data-limit="{{ limit }}"
|
data-limit="{{ limit }}"
|
||||||
data-last-ts="{{ last_ts }}"
|
data-last-ts="{{ last_ts }}"
|
||||||
data-ws-url="{{ compose_ws_url }}">
|
data-ws-url="{{ compose_ws_url }}">
|
||||||
{% for msg in serialized_messages %}
|
{% include "partials/compose-message-rows.html" with message_rows=serialized_messages show_empty_state=True empty_message="No stored messages for this contact yet." %}
|
||||||
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}{% if msg.is_deleted %} is-deleted{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}"{% if msg.reply_to_id %} data-reply-to-id="{{ msg.reply_to_id }}"{% endif %} data-reply-snippet="{{ msg.display_text|default:msg.text|default:''|truncatechars:120|escape }}">
|
|
||||||
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
|
||||||
{% if msg.reply_to_id %}
|
|
||||||
<div class="compose-reply-ref" data-reply-target-id="{{ msg.reply_to_id }}" data-reply-preview="{{ msg.reply_preview|default:''|escape }}">
|
|
||||||
<button type="button" class="compose-reply-link" title="Jump to referenced message"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="compose-source-badge-wrap">
|
|
||||||
<span class="tag is-light compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span>
|
|
||||||
</div>
|
|
||||||
{% if msg.image_urls %}
|
|
||||||
{% for image_url in msg.image_urls %}
|
|
||||||
<figure class="compose-media">
|
|
||||||
<img
|
|
||||||
class="compose-image"
|
|
||||||
src="{{ image_url }}"
|
|
||||||
alt="Attachment"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async">
|
|
||||||
</figure>
|
|
||||||
{% endfor %}
|
|
||||||
{% elif msg.image_url %}
|
|
||||||
<figure class="compose-media">
|
|
||||||
<img
|
|
||||||
class="compose-image"
|
|
||||||
src="{{ msg.image_url }}"
|
|
||||||
alt="Attachment"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async">
|
|
||||||
</figure>
|
|
||||||
{% endif %}
|
|
||||||
{% if not msg.hide_text %}
|
|
||||||
<p class="compose-body">{{ msg.display_text|default:"(no text)" }}</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="compose-body compose-image-fallback is-hidden">(no text)</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if msg.edit_count %}
|
|
||||||
<details class="compose-edit-history">
|
|
||||||
<summary>Edited {{ msg.edit_count }} time{% if msg.edit_count != 1 %}s{% endif %}</summary>
|
|
||||||
<ul>
|
|
||||||
{% for edit in msg.edit_history %}
|
|
||||||
<li>
|
|
||||||
{% if edit.edited_display %}{{ edit.edited_display }}{% else %}Unknown time{% endif %}
|
|
||||||
{% if edit.actor %} · {{ edit.actor }}{% endif %}
|
|
||||||
{% if edit.source_service %} · {{ edit.source_service|upper }}{% endif %}
|
|
||||||
<div class="compose-edit-diff">
|
|
||||||
<span class="compose-edit-old">{{ edit.previous_text|default:"(empty)" }}</span>
|
|
||||||
<span class="compose-edit-arrow">→</span>
|
|
||||||
<span class="compose-edit-new">{{ edit.new_text|default:"(empty)" }}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
{% if msg.reactions %}
|
|
||||||
<div class="compose-reactions" aria-label="Message reactions">
|
|
||||||
{% for reaction in msg.reactions %}
|
|
||||||
<span
|
|
||||||
class="tag is-light compose-reaction-chip"
|
|
||||||
data-emoji="{{ reaction.emoji|escape }}"
|
|
||||||
data-actor="{{ reaction.actor|default:''|escape }}"
|
|
||||||
data-source-service="{{ reaction.source_service|default:''|escape }}"
|
|
||||||
title="{{ reaction.actor|default:'Unknown' }} via {{ reaction.source_service|default:'unknown'|upper }}">
|
|
||||||
{{ reaction.emoji }}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<p class="compose-msg-meta">
|
|
||||||
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
|
|
||||||
{% if msg.is_edited %}
|
|
||||||
<span class="tag is-light compose-msg-flag is-edited" title="Message edited{% if msg.last_edit_display %} at {{ msg.last_edit_display }}{% endif %}">edited</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if msg.is_deleted %}
|
|
||||||
<span class="tag is-light compose-msg-flag is-deleted" title="Deleted{% if msg.deleted_display %} at {{ msg.deleted_display }}{% endif %}{% if msg.deleted_actor %} by {{ msg.deleted_actor }}{% endif %}">deleted</span>
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<button type="button" class="button is-white is-small compose-reply-btn" title="Reply to this message" aria-label="Reply to this message">
|
|
||||||
<span class="icon is-small"><i class="fa-solid fa-reply"></i></span>
|
|
||||||
<span class="compose-reply-btn-label">Reply</span>
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
{% empty %}
|
|
||||||
<p class="compose-empty">No stored messages for this contact yet.</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<p id="{{ panel_id }}-typing" class="compose-typing is-hidden">
|
<p id="{{ panel_id }}-typing" class="compose-typing is-hidden">
|
||||||
{% if person %}{{ person.name }}{% else %}Contact{% endif %} is typing...
|
{% if person %}{{ person.name }}{% else %}Contact{% endif %} is typing...
|
||||||
@@ -198,8 +113,15 @@
|
|||||||
<input type="hidden" name="failsafe_arm" value="0">
|
<input type="hidden" name="failsafe_arm" value="0">
|
||||||
<input type="hidden" name="failsafe_confirm" value="0">
|
<input type="hidden" name="failsafe_confirm" value="0">
|
||||||
<div class="compose-send-safety">
|
<div class="compose-send-safety">
|
||||||
<label class="checkbox is-size-7">
|
<label class="checkbox is-size-7" for="{{ panel_id }}-manual-confirm">
|
||||||
<input type="checkbox" class="manual-confirm"{% if not capability_send %} disabled{% endif %}> Confirm Send
|
<input
|
||||||
|
id="{{ panel_id }}-manual-confirm"
|
||||||
|
type="checkbox"
|
||||||
|
class="manual-confirm"
|
||||||
|
name="manual_confirm"
|
||||||
|
value="1"
|
||||||
|
{% if not capability_send %}disabled{% endif %}>
|
||||||
|
Confirm Send
|
||||||
</label>
|
</label>
|
||||||
{% if not capability_send %}
|
{% if not capability_send %}
|
||||||
<p class="help is-size-7 has-text-grey">Send disabled: {{ capability_send_reason }}</p>
|
<p class="help is-size-7 has-text-grey">Send disabled: {{ capability_send_reason }}</p>
|
||||||
@@ -210,19 +132,6 @@
|
|||||||
<span id="{{ panel_id }}-reply-text" class="compose-reply-banner-text"></span>
|
<span id="{{ panel_id }}-reply-text" class="compose-reply-banner-text"></span>
|
||||||
<button type="button" id="{{ panel_id }}-reply-clear" class="button is-white is-small compose-reply-clear-btn">Clear</button>
|
<button type="button" id="{{ panel_id }}-reply-clear" class="button is-white is-small compose-reply-clear-btn">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="compose-composer-capsule box is-shadowless">
|
{% include "partials/bulma-send-composer.html" with composer_class="compose-composer-capsule" textarea_id=panel_id|add:"-textarea" textarea_class="compose-textarea" textarea_name="text" textarea_rows="1" textarea_placeholder="Type a message. Enter to send, Shift+Enter for newline." button_class="is-link is-light compose-send-btn" button_type="submit" button_disabled=True button_title=capability_send_reason|default_if_none:"" button_label="Send" button_icon_class=manual_icon_class %}
|
||||||
<textarea
|
|
||||||
id="{{ panel_id }}-textarea"
|
|
||||||
class="textarea compose-textarea"
|
|
||||||
name="text"
|
|
||||||
rows="1"
|
|
||||||
placeholder="Type a message. Enter to send, Shift+Enter for newline."></textarea>
|
|
||||||
<button class="button is-link is-light compose-send-btn" type="submit" disabled{% if not capability_send %} title="{{ capability_send_reason }}"{% endif %}>
|
|
||||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
|
||||||
<span>Send</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{% static 'js/compose-panel.js' %}"></script>
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<div id="{{ results_id }}">
|
||||||
|
<p class="is-size-7 has-text-grey mb-3">
|
||||||
|
{% if total_matches %}
|
||||||
|
{% if is_search_results %}
|
||||||
|
Showing {{ visible_count }} of {{ total_matches }} contacts.
|
||||||
|
{% elif result_mode == "active_chats" %}
|
||||||
|
Recent chats. Search to see all {{ total_contacts }} contacts.
|
||||||
|
{% else %}
|
||||||
|
Showing {{ visible_count }} contacts.
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if is_search_results %}
|
||||||
|
No contacts found.
|
||||||
|
{% else %}
|
||||||
|
No contacts yet.
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if contact_rows %}
|
||||||
|
<nav class="panel">
|
||||||
|
{% for row in contact_rows %}
|
||||||
|
<a
|
||||||
|
class="panel-block"
|
||||||
|
hx-get="{{ row.compose_widget_url }}"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
<span class="is-flex is-justify-content-space-between is-align-items-center is-flex-grow-1" style="min-width: 0; gap: 0.75rem;">
|
||||||
|
<span style="min-width: 0;">
|
||||||
|
<span class="has-text-weight-semibold">{{ row.person_name }}</span>
|
||||||
|
<span class="is-size-7 has-text-grey is-block" style="overflow-wrap: anywhere;">
|
||||||
|
{{ row.identifier }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="tag is-light gia-badge">{{ row.service|title }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_more %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<button
|
||||||
|
class="button is-small is-fullwidth is-light"
|
||||||
|
hx-get="{{ results_url }}"
|
||||||
|
hx-include="#{{ launcher_form_id }}"
|
||||||
|
hx-vals='{"page": "{{ next_page }}"}'
|
||||||
|
hx-target="#{{ results_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Show {{ next_count }} more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -1,117 +1,31 @@
|
|||||||
<div class="compose-workspace-widget">
|
<div class="compose-workspace-widget">
|
||||||
<div class="columns is-mobile is-gapless">
|
<div class="mb-3">
|
||||||
<div class="column is-12-mobile is-12-tablet">
|
<h3 class="title is-6 mb-1">Contacts</h3>
|
||||||
<div
|
<p class="is-size-7 has-text-grey">
|
||||||
style="
|
See all your contacts. Search to narrow the list.
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
padding: 0.5rem 0.25rem;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
|
||||||
">
|
|
||||||
<p class="is-size-7 has-text-weight-semibold">Manual Workspace</p>
|
|
||||||
<h3 class="title is-6" style="margin-bottom: 0.5rem;">Choose A Contact</h3>
|
|
||||||
<p class="is-size-7">
|
|
||||||
Open one or more direct chat widgets and keep them live in this workspace.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form id="{{ launcher_form_id }}">
|
||||||
id="compose-workspace-window-form"
|
<div class="field">
|
||||||
style="
|
<label class="label is-small" for="{{ search_input_id }}">Search</label>
|
||||||
margin-bottom: 0.75rem;
|
<div class="control has-icons-left">
|
||||||
padding: 0.5rem 0.25rem;
|
<input
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
id="{{ search_input_id }}"
|
||||||
">
|
class="input is-small"
|
||||||
<label class="label is-small" for="compose-workspace-limit">Window</label>
|
type="search"
|
||||||
<div class="select is-fullwidth is-small">
|
name="q"
|
||||||
<select id="compose-workspace-limit" name="limit">
|
value="{{ search_query }}"
|
||||||
{% for option in limit_options %}
|
placeholder="Name, identifier, or service"
|
||||||
<option value="{{ option }}" {% if option == limit %}selected{% endif %}>
|
hx-get="{{ results_url }}"
|
||||||
{{ option }} messages
|
hx-trigger="input changed delay:250ms, search"
|
||||||
</option>
|
hx-target="#{{ results_id }}"
|
||||||
{% endfor %}
|
hx-include="#{{ launcher_form_id }}"
|
||||||
</select>
|
hx-swap="outerHTML">
|
||||||
|
<span class="icon is-small is-left"><i class="fa-solid fa-magnifying-glass"></i></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="help">
|
|
||||||
How many recent messages to load in each new message widget.
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div>
|
{% include "partials/compose-workspace-contact-results.html" %}
|
||||||
{% if contact_rows %}
|
|
||||||
<div class="buttons are-small" style="display: grid; gap: 0.5rem;">
|
|
||||||
{% for row in contact_rows %}
|
|
||||||
<button
|
|
||||||
class="button is-fullwidth"
|
|
||||||
style="
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 0;
|
|
||||||
"
|
|
||||||
hx-get="{{ row.compose_widget_url }}"
|
|
||||||
hx-include="#compose-workspace-window-form"
|
|
||||||
hx-target="#widgets-here"
|
|
||||||
hx-swap="afterend">
|
|
||||||
<span
|
|
||||||
class="tags has-addons"
|
|
||||||
style="
|
|
||||||
display: inline-flex;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
">
|
|
||||||
<span
|
|
||||||
class="tag is-white"
|
|
||||||
style="
|
|
||||||
flex: 1;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding-left: 0.7rem;
|
|
||||||
padding-right: 0.7rem;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
||||||
min-width: 0;
|
|
||||||
">
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.45rem;
|
|
||||||
min-width: 0;
|
|
||||||
">
|
|
||||||
<strong>{{ row.person_name }}</strong>
|
|
||||||
<small class="has-text-grey">{{ row.service|title }}</small>
|
|
||||||
</span>
|
|
||||||
<small
|
|
||||||
class="has-text-grey"
|
|
||||||
style="
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
word-break: break-all;
|
|
||||||
text-align: right;
|
|
||||||
">
|
|
||||||
{{ row.identifier }}
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{% if not row.linked_person %}
|
|
||||||
<a
|
|
||||||
class="button is-small is-light"
|
|
||||||
href="{{ row.match_url }}"
|
|
||||||
title="Link this identifier to a person">
|
|
||||||
<span class="icon is-small"><i class="fa-solid fa-link"></i></span>
|
|
||||||
<span>Match</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="has-text-grey">No contacts available yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<div id="{{ results_id }}">
|
||||||
|
<p class="is-size-7 has-text-grey mb-3">
|
||||||
|
{% if history_rows %}
|
||||||
|
Showing {{ result_start }}-{{ result_end }} recent matches.
|
||||||
|
{% else %}
|
||||||
|
No persisted messages match this search.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if history_rows %}
|
||||||
|
<div class="is-flex is-flex-direction-column" style="gap: 0.5rem;">
|
||||||
|
{% for row in history_rows %}
|
||||||
|
<div class="box is-shadowless p-3 m-0">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-flex-start" style="gap: 0.75rem;">
|
||||||
|
<div style="min-width: 0;">
|
||||||
|
<p class="has-text-weight-semibold mb-1">
|
||||||
|
{{ row.person_name }}
|
||||||
|
<span class="tag is-light gia-badge ml-2">{{ row.service_label }}</span>
|
||||||
|
<span class="tag {% if row.outgoing %}is-warning{% else %}is-info{% endif %} is-light gia-badge ml-1">{{ row.direction_label }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="is-size-7 has-text-grey mb-1" style="overflow-wrap: anywhere;">
|
||||||
|
{{ row.identifier }} · {{ row.display_ts }}
|
||||||
|
</p>
|
||||||
|
<p class="mb-0" style="overflow-wrap: anywhere;">
|
||||||
|
{{ row.text_preview }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="buttons are-small m-0">
|
||||||
|
<button
|
||||||
|
class="button is-small is-link is-light"
|
||||||
|
hx-get="{{ row.compose_widget_url }}"
|
||||||
|
hx-include="#{{ browser_form_id }}"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
|
||||||
|
<span>Open</span>
|
||||||
|
</button>
|
||||||
|
<a class="button is-small is-light" href="{{ row.compose_page_url }}">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-arrow-up-right-from-square"></i></span>
|
||||||
|
<span>Page</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_more %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<button
|
||||||
|
class="button is-small is-fullwidth is-light"
|
||||||
|
hx-get="{{ results_url }}"
|
||||||
|
hx-include="#{{ browser_form_id }}"
|
||||||
|
hx-vals='{"page": "{{ next_page }}"}'
|
||||||
|
hx-target="#{{ results_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Show more history
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
108
core/templates/partials/compose-workspace-history-widget.html
Normal file
108
core/templates/partials/compose-workspace-history-widget.html
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<div class="compose-workspace-widget">
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold">Manual Workspace</p>
|
||||||
|
<h3 class="title is-6 mb-2">Browse Message History</h3>
|
||||||
|
<p class="is-size-7">
|
||||||
|
Filter persisted messages across contacts, then reopen the matching live thread widget.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="{{ browser_form_id }}">
|
||||||
|
{% if person_scope_id %}
|
||||||
|
<input type="hidden" name="person" value="{{ person_scope_id }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small" for="{{ search_input_id }}">Find Messages</label>
|
||||||
|
<div class="control has-icons-left">
|
||||||
|
<input
|
||||||
|
id="{{ search_input_id }}"
|
||||||
|
class="input is-small"
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
value="{{ search_query }}"
|
||||||
|
placeholder="Person, identifier, or message text"
|
||||||
|
hx-get="{{ results_url }}"
|
||||||
|
hx-trigger="input changed delay:250ms, search"
|
||||||
|
hx-target="#{{ results_id }}"
|
||||||
|
hx-include="#{{ browser_form_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<span class="icon is-small is-left"><i class="fa-solid fa-magnifying-glass"></i></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns is-mobile is-multiline mb-1">
|
||||||
|
<div class="column is-half pt-0 pb-2">
|
||||||
|
<label class="label is-small" for="{{ service_input_id }}">Service</label>
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select
|
||||||
|
id="{{ service_input_id }}"
|
||||||
|
name="service"
|
||||||
|
hx-get="{{ results_url }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#{{ results_id }}"
|
||||||
|
hx-include="#{{ browser_form_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{% for value, label in service_options %}
|
||||||
|
<option value="{{ value }}" {% if value == service %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half pt-0 pb-2">
|
||||||
|
<label class="label is-small" for="{{ direction_input_id }}">Direction</label>
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select
|
||||||
|
id="{{ direction_input_id }}"
|
||||||
|
name="direction"
|
||||||
|
hx-get="{{ results_url }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#{{ results_id }}"
|
||||||
|
hx-include="#{{ browser_form_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{% for value, label in direction_options %}
|
||||||
|
<option value="{{ value }}" {% if value == direction %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half pt-0 pb-2">
|
||||||
|
<label class="label is-small" for="{{ days_input_id }}">Range</label>
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select
|
||||||
|
id="{{ days_input_id }}"
|
||||||
|
name="days"
|
||||||
|
hx-get="{{ results_url }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#{{ results_id }}"
|
||||||
|
hx-include="#{{ browser_form_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{% for value, label in days_options %}
|
||||||
|
<option value="{{ value }}" {% if value == days %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half pt-0 pb-2">
|
||||||
|
<label class="label is-small" for="{{ thread_limit_input_id }}">Thread Window</label>
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select id="{{ thread_limit_input_id }}" name="limit">
|
||||||
|
{% for option in thread_limit_options %}
|
||||||
|
<option value="{{ option }}" {% if option == limit %}selected{% endif %}>
|
||||||
|
{{ option }} messages
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if person_scope %}
|
||||||
|
<p class="help mb-3">
|
||||||
|
Scoped to <strong>{{ person_scope.name }}</strong>.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% include "partials/compose-workspace-history-results.html" %}
|
||||||
|
</div>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" title="Copy to clipboard" aria-label="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" title="Copy to clipboard" aria-label="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" title="Copy to clipboard" aria-label="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.sender_uuid }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.sender_uuid }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" title="Copy to clipboard" aria-label="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
<a class="navbar-item" href="{% url 'compose_workspace' %}">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
|
||||||
|
<span style="margin-left: 0.35rem;">Compose Workspace</span>
|
||||||
|
</a>
|
||||||
|
<hr class="navbar-divider" style="margin: 0.2rem 0;">
|
||||||
{% if items %}
|
{% if items %}
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<a class="navbar-item" href="{{ item.compose_url }}">
|
<a class="navbar-item" href="{{ item.compose_url }}">
|
||||||
|
|||||||
@@ -13,8 +13,7 @@
|
|||||||
class="button osint-capsule-tab"
|
class="button osint-capsule-tab"
|
||||||
hx-get="{{ tab.widget_url }}"
|
hx-get="{{ tab.widget_url }}"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
hx-swap="afterend"
|
hx-swap="beforeend"
|
||||||
onclick="document.getElementById('widgets-here').style.display='block';"
|
|
||||||
title="Open {{ tab.label }} setup widget">
|
title="Open {{ tab.label }} setup widget">
|
||||||
<span class="icon is-small"><i class="{{ tab.icon }}"></i></span>
|
<span class="icon is-small"><i class="{{ tab.icon }}"></i></span>
|
||||||
<span>{{ tab.label }}</span>
|
<span>{{ tab.label }}</span>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
hx-get="{{ action.url }}"
|
hx-get="{{ action.url }}"
|
||||||
hx-target="{{ action.target }}"
|
hx-target="{{ action.target }}"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
{% if action.target == "#windows-here" %}onclick="window.giaPrepareWindowAnchor(this);"{% endif %}
|
{% if action.target == "#windows-here" %}onclick="if (window.giaPrepareWindowAnchor) { window.giaPrepareWindowAnchor(this); }"{% endif %}
|
||||||
title="{{ action.title }}">
|
title="{{ action.title }}">
|
||||||
<span class="icon"><i class="{{ action.icon }}"></i></span>
|
<span class="icon"><i class="{{ action.icon }}"></i></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" title="Copy to clipboard" aria-label="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" title="Copy to clipboard" aria-label="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -37,7 +37,9 @@
|
|||||||
class="button is-success is-light"
|
class="button is-success is-light"
|
||||||
hx-get="{% url 'message_accept_api' message_id=item.id %}"
|
hx-get="{% url 'message_accept_api' message_id=item.id %}"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
_="on htmx:afterRequest if event.detail.successful remove #queue-card-{{ item.id }} then trigger {{ context_object_name_singular }}Event on body end">
|
data-queue-after="remove-card"
|
||||||
|
data-queue-card-id="queue-card-{{ item.id }}"
|
||||||
|
data-queue-refresh-event="{{ context_object_name_singular }}Event">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||||
<span>Approve</span>
|
<span>Approve</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -45,7 +47,9 @@
|
|||||||
class="button is-danger is-light"
|
class="button is-danger is-light"
|
||||||
hx-get="{% url 'message_reject_api' message_id=item.id %}"
|
hx-get="{% url 'message_reject_api' message_id=item.id %}"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
_="on htmx:afterRequest if event.detail.successful remove #queue-card-{{ item.id }} then trigger {{ context_object_name_singular }}Event on body end">
|
data-queue-after="remove-card"
|
||||||
|
data-queue-card-id="queue-card-{{ item.id }}"
|
||||||
|
data-queue-refresh-event="{{ context_object_name_singular }}Event">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-xmark"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-xmark"></i></span>
|
||||||
<span>Reject</span>
|
<span>Reject</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -65,7 +69,8 @@
|
|||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="#queue-inline-editor-{{ item.id }}"
|
hx-target="#queue-inline-editor-{{ item.id }}"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
_="on htmx:afterRequest if event.detail.successful set #queue-inline-editor-{{ item.id }}.style.display to 'block' end"
|
data-queue-after="show-inline-editor"
|
||||||
|
data-queue-editor-id="queue-inline-editor-{{ item.id }}"
|
||||||
class="button is-light">
|
class="button is-light">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" title="Copy to clipboard" aria-label="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{% if settings_nav %}
|
{% if settings_nav %}
|
||||||
<h1 class="title is-4">{{ settings_nav.title }}</h1>
|
<nav class="gia-settings-nav" aria-label="Settings navigation">
|
||||||
<div class="tabs is-boxed is-small mb-4 security-page-tabs">
|
{% if settings_nav.groups %}
|
||||||
|
<div class="tabs is-boxed is-small mb-2 security-page-tabs gia-settings-nav-groups">
|
||||||
<ul>
|
<ul>
|
||||||
{% for tab in settings_nav.tabs %}
|
{% for tab in settings_nav.groups %}
|
||||||
<li class="{% if tab.active %}is-active{% endif %}">
|
<li class="{% if tab.active %}is-active{% endif %}">
|
||||||
<a href="{{ tab.href }}">{{ tab.label }}</a>
|
<a href="{{ tab.href }}">{{ tab.label }}</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -10,3 +11,18 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% for row in settings_nav.rows %}
|
||||||
|
{% if row.tabs|length > 1 %}
|
||||||
|
<div class="tabs is-toggle is-toggle-rounded is-small security-page-tabs gia-settings-nav-row {% if forloop.last %}mb-4{% else %}mb-2{% endif %}">
|
||||||
|
<ul>
|
||||||
|
{% for tab in row.tabs %}
|
||||||
|
<li class="{% if tab.active %}is-active{% endif %}">
|
||||||
|
<a href="{{ tab.href }}">{{ tab.label }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" title="Copy to clipboard" aria-label="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
hx-get="{{ item.compose_widget_url }}"
|
hx-get="{{ item.compose_widget_url }}"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
hx-swap="afterend"
|
hx-swap="beforeend"
|
||||||
class="button">
|
class="button">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.uuid }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.uuid }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" title="Copy to clipboard" aria-label="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.identifier }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.identifier }}');">
|
||||||
<span class="icon" data-tooltip="Copy identifier">
|
<span class="icon" title="Copy identifier" aria-label="Copy identifier">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
hx-get="{{ item.compose_widget_url }}"
|
hx-get="{{ item.compose_widget_url }}"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
hx-swap="afterend"
|
hx-swap="beforeend"
|
||||||
class="button"
|
class="button"
|
||||||
title="Manual text mode widget">
|
title="Manual text mode widget">
|
||||||
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
hx-get="{{ item.compose_widget_url }}"
|
hx-get="{{ item.compose_widget_url }}"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
hx-swap="afterend"
|
hx-swap="beforeend"
|
||||||
class="button"
|
class="button"
|
||||||
title="Open manual chat widget">
|
title="Open manual chat widget">
|
||||||
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
||||||
|
|||||||
7
core/templates/partials/workspace-widget-loader.html
Normal file
7
core/templates/partials/workspace-widget-loader.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<div
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{{ widget_url }}"
|
||||||
|
hx-target="{% firstof target_selector '#widgets-here' %}"
|
||||||
|
hx-trigger="{% firstof trigger_name 'load' %}{% if trigger_delay %} delay:{{ trigger_delay }}{% endif %}"
|
||||||
|
hx-swap="{% firstof swap_strategy 'beforeend' %}"
|
||||||
|
style="display: none;"></div>
|
||||||
@@ -1 +1,9 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content_wrapper %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from subprocess import CompletedProcess, TimeoutExpired
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.test import SimpleTestCase
|
|
||||||
|
|
||||||
from core.tasks.providers.claude_cli import ClaudeCLITaskProvider
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCLITaskProviderTests(SimpleTestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.provider = ClaudeCLITaskProvider()
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.claude_cli.subprocess.run")
|
|
||||||
def test_healthcheck_success(self, run_mock):
|
|
||||||
run_mock.return_value = CompletedProcess(
|
|
||||||
args=["claude", "--version"],
|
|
||||||
returncode=0,
|
|
||||||
stdout="claude 1.0.0\n",
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
result = self.provider.healthcheck({"command": "claude", "timeout_seconds": 5})
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertIn("claude", str(result.payload.get("stdout") or ""))
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.claude_cli.subprocess.run")
|
|
||||||
def test_create_task_builds_task_sync_command(self, run_mock):
|
|
||||||
run_mock.return_value = CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=0,
|
|
||||||
stdout='{"external_key":"cl-123"}',
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
result = self.provider.create_task(
|
|
||||||
{
|
|
||||||
"command": "claude",
|
|
||||||
"workspace_root": "/tmp/work",
|
|
||||||
"default_profile": "default",
|
|
||||||
"timeout_seconds": 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"task_id": "t1",
|
|
||||||
"title": "hello",
|
|
||||||
"reference_code": "42",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertEqual("cl-123", result.external_key)
|
|
||||||
args = run_mock.call_args.args[0]
|
|
||||||
self.assertEqual(["claude", "task-sync", "--op", "create"], args[:4])
|
|
||||||
self.assertIn("--workspace", args)
|
|
||||||
self.assertIn("--payload-json", args)
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.claude_cli.subprocess.run")
|
|
||||||
def test_timeout_maps_to_failed_result(self, run_mock):
|
|
||||||
run_mock.side_effect = TimeoutExpired(cmd=["claude"], timeout=10)
|
|
||||||
result = self.provider.append_update(
|
|
||||||
{"command": "claude", "timeout_seconds": 10}, {"task_id": "t1"}
|
|
||||||
)
|
|
||||||
self.assertFalse(result.ok)
|
|
||||||
self.assertIn("timeout", result.error)
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.claude_cli.subprocess.run")
|
|
||||||
def test_requires_approval_parsed_from_stdout(self, run_mock):
|
|
||||||
run_mock.return_value = CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=0,
|
|
||||||
stdout='{"status":"requires_approval","approval_key":"ak-1","permission_request":{"requested_permissions":["write"]}}',
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
result = self.provider.append_update({"command": "claude"}, {"task_id": "t1"})
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
|
|
||||||
self.assertEqual(
|
|
||||||
"requires_approval", (result.payload or {}).get("parsed_status")
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.claude_cli.subprocess.run")
|
|
||||||
def test_retries_with_positional_op_when_flag_unsupported(self, run_mock):
|
|
||||||
run_mock.side_effect = [
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=2,
|
|
||||||
stdout="",
|
|
||||||
stderr="error: unexpected argument '--op' found",
|
|
||||||
),
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=0,
|
|
||||||
stdout='{"status":"ok","external_key":"cl-42"}',
|
|
||||||
stderr="",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
result = self.provider.create_task({"command": "claude"}, {"task_id": "t1"})
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertEqual("cl-42", result.external_key)
|
|
||||||
self.assertEqual(2, run_mock.call_count)
|
|
||||||
first = run_mock.call_args_list[0].args[0]
|
|
||||||
second = run_mock.call_args_list[1].args[0]
|
|
||||||
self.assertIn("--op", first)
|
|
||||||
self.assertNotIn("--op", second)
|
|
||||||
self.assertEqual(["claude", "task-sync", "create"], second[:3])
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.claude_cli.subprocess.run")
|
|
||||||
def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(
|
|
||||||
self, run_mock
|
|
||||||
):
|
|
||||||
run_mock.side_effect = [
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=2,
|
|
||||||
stdout="",
|
|
||||||
stderr="error: unexpected argument '--op' found",
|
|
||||||
),
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=2,
|
|
||||||
stdout="",
|
|
||||||
stderr="error: unrecognized subcommand 'create'\nUsage: claude [OPTIONS] [PROMPT]",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
result = self.provider.create_task(
|
|
||||||
{"command": "claude"},
|
|
||||||
{
|
|
||||||
"task_id": "t1",
|
|
||||||
"trigger_message_id": "m1",
|
|
||||||
"mode": "default",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
|
|
||||||
self.assertEqual(
|
|
||||||
"requires_approval", str((result.payload or {}).get("status") or "")
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
"builtin_task_sync_stub",
|
|
||||||
str((result.payload or {}).get("fallback_mode") or ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.claude_cli.subprocess.run")
|
|
||||||
def test_builtin_stub_approval_response_returns_ok(self, run_mock):
|
|
||||||
run_mock.side_effect = [
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=2,
|
|
||||||
stdout="",
|
|
||||||
stderr="error: unexpected argument '--op' found",
|
|
||||||
),
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=2,
|
|
||||||
stdout="",
|
|
||||||
stderr="error: unexpected argument 'append_update' found\nUsage: claude [OPTIONS] [PROMPT]",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
result = self.provider.append_update(
|
|
||||||
{"command": "claude"},
|
|
||||||
{
|
|
||||||
"task_id": "t1",
|
|
||||||
"mode": "approval_response",
|
|
||||||
"approval_key": "abc123",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertFalse(bool((result.payload or {}).get("requires_approval")))
|
|
||||||
self.assertEqual("ok", str((result.payload or {}).get("status") or ""))
|
|
||||||
|
|
||||||
def test_provider_name_and_run_in_worker(self):
|
|
||||||
self.assertEqual("claude_cli", self.provider.name)
|
|
||||||
self.assertTrue(self.provider.run_in_worker)
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.claude_cli.subprocess.run")
|
|
||||||
def test_healthcheck_failure(self, run_mock):
|
|
||||||
run_mock.return_value = CompletedProcess(
|
|
||||||
args=["claude", "--version"],
|
|
||||||
returncode=1,
|
|
||||||
stdout="",
|
|
||||||
stderr="command not found: claude",
|
|
||||||
)
|
|
||||||
result = self.provider.healthcheck({"command": "claude"})
|
|
||||||
self.assertFalse(result.ok)
|
|
||||||
self.assertIn("command not found", result.error)
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from core.commands.base import CommandContext
|
|
||||||
from core.commands.engine import process_inbound_message
|
|
||||||
from core.commands.handlers.claude import parse_claude_command
|
|
||||||
from core.models import (
|
|
||||||
ChatSession,
|
|
||||||
CodexPermissionRequest,
|
|
||||||
CodexRun,
|
|
||||||
CommandChannelBinding,
|
|
||||||
CommandProfile,
|
|
||||||
DerivedTask,
|
|
||||||
ExternalSyncEvent,
|
|
||||||
Message,
|
|
||||||
Person,
|
|
||||||
PersonIdentifier,
|
|
||||||
TaskProject,
|
|
||||||
TaskProviderConfig,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCommandParserTests(TestCase):
|
|
||||||
def test_parse_variants(self):
|
|
||||||
self.assertEqual("default", parse_claude_command("#claude# run this").command)
|
|
||||||
self.assertEqual("plan", parse_claude_command("#claude plan# run this").command)
|
|
||||||
self.assertEqual("status", parse_claude_command("#claude status#").command)
|
|
||||||
parsed = parse_claude_command("#claude approve abc123#")
|
|
||||||
self.assertEqual("approve", parsed.command)
|
|
||||||
self.assertEqual("abc123", parsed.approval_key)
|
|
||||||
self.assertEqual("default", parse_claude_command(".claude run this").command)
|
|
||||||
self.assertEqual("plan", parse_claude_command(".CLAUDE plan run this").command)
|
|
||||||
self.assertEqual("status", parse_claude_command(".claude status").command)
|
|
||||||
parsed_dot = parse_claude_command(".claude approve abc123")
|
|
||||||
self.assertEqual("approve", parsed_dot.command)
|
|
||||||
self.assertEqual("abc123", parsed_dot.approval_key)
|
|
||||||
|
|
||||||
def test_no_match_returns_none_command(self):
|
|
||||||
self.assertIsNone(parse_claude_command("hello world").command)
|
|
||||||
self.assertIsNone(parse_claude_command(".codex do this").command)
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCommandExecutionTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(
|
|
||||||
"claude-cmd-user", "claude-cmd@example.com", "x"
|
|
||||||
)
|
|
||||||
self.person = Person.objects.create(user=self.user, name="Claude Cmd")
|
|
||||||
self.identifier = PersonIdentifier.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
person=self.person,
|
|
||||||
service="web",
|
|
||||||
identifier="web-chan-1",
|
|
||||||
)
|
|
||||||
self.session = ChatSession.objects.create(
|
|
||||||
user=self.user, identifier=self.identifier
|
|
||||||
)
|
|
||||||
self.project = TaskProject.objects.create(user=self.user, name="Project A")
|
|
||||||
self.task = DerivedTask.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
project=self.project,
|
|
||||||
epic=None,
|
|
||||||
title="Task A",
|
|
||||||
source_service="web",
|
|
||||||
source_channel="web-chan-1",
|
|
||||||
reference_code="1",
|
|
||||||
status_snapshot="open",
|
|
||||||
)
|
|
||||||
self.profile = CommandProfile.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
slug="claude",
|
|
||||||
name="Claude",
|
|
||||||
enabled=True,
|
|
||||||
trigger_token="#claude#",
|
|
||||||
reply_required=False,
|
|
||||||
exact_match_only=False,
|
|
||||||
)
|
|
||||||
CommandChannelBinding.objects.create(
|
|
||||||
profile=self.profile,
|
|
||||||
direction="ingress",
|
|
||||||
service="web",
|
|
||||||
channel_identifier="web-chan-1",
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
TaskProviderConfig.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
provider="claude_cli",
|
|
||||||
enabled=True,
|
|
||||||
settings={
|
|
||||||
"command": "claude",
|
|
||||||
"workspace_root": "",
|
|
||||||
"default_profile": "",
|
|
||||||
"timeout_seconds": 60,
|
|
||||||
"chat_link_mode": "task-sync",
|
|
||||||
"instance_label": "default",
|
|
||||||
"approver_mode": "channel",
|
|
||||||
"approver_service": "web",
|
|
||||||
"approver_identifier": "approver-chan",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _msg(self, text: str, *, source_chat_id: str = "web-chan-1", reply_to=None):
|
|
||||||
return Message.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
session=self.session,
|
|
||||||
sender_uuid="",
|
|
||||||
text=text,
|
|
||||||
ts=1000 + Message.objects.filter(user=self.user).count(),
|
|
||||||
source_service="web",
|
|
||||||
source_chat_id=source_chat_id,
|
|
||||||
reply_to=reply_to,
|
|
||||||
message_meta={},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_default_submission_creates_run_and_event(self):
|
|
||||||
trigger = self._msg("#claude# please update #1")
|
|
||||||
results = async_to_sync(process_inbound_message)(
|
|
||||||
CommandContext(
|
|
||||||
service="web",
|
|
||||||
channel_identifier="web-chan-1",
|
|
||||||
message_id=str(trigger.id),
|
|
||||||
user_id=self.user.id,
|
|
||||||
message_text=str(trigger.text),
|
|
||||||
payload={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(1, len(results))
|
|
||||||
self.assertTrue(results[0].ok)
|
|
||||||
run = CodexRun.objects.order_by("-created_at").first()
|
|
||||||
self.assertIsNotNone(run)
|
|
||||||
self.assertEqual("waiting_approval", run.status)
|
|
||||||
event = ExternalSyncEvent.objects.order_by("-created_at").first()
|
|
||||||
self.assertEqual("waiting_approval", event.status)
|
|
||||||
self.assertEqual(
|
|
||||||
"default",
|
|
||||||
str((event.payload or {}).get("provider_payload", {}).get("mode") or ""),
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
CodexPermissionRequest.objects.filter(
|
|
||||||
user=self.user,
|
|
||||||
codex_run=run,
|
|
||||||
status="pending",
|
|
||||||
).exists()
|
|
||||||
)
|
|
||||||
# The approval notification must reference ".claude approve" not ".codex approve"
|
|
||||||
req = CodexPermissionRequest.objects.get(codex_run=run, status="pending")
|
|
||||||
approval_key = str(req.approval_key or "")
|
|
||||||
# The approval_key should exist
|
|
||||||
self.assertTrue(bool(approval_key))
|
|
||||||
|
|
||||||
def test_plan_requires_reply_anchor(self):
|
|
||||||
trigger = self._msg("#claude plan# #1")
|
|
||||||
results = async_to_sync(process_inbound_message)(
|
|
||||||
CommandContext(
|
|
||||||
service="web",
|
|
||||||
channel_identifier="web-chan-1",
|
|
||||||
message_id=str(trigger.id),
|
|
||||||
user_id=self.user.id,
|
|
||||||
message_text=str(trigger.text),
|
|
||||||
payload={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(1, len(results))
|
|
||||||
self.assertFalse(results[0].ok)
|
|
||||||
self.assertEqual("reply_required_for_claude_plan", results[0].error)
|
|
||||||
|
|
||||||
def test_approve_command_queues_resume_event(self):
|
|
||||||
waiting_event = ExternalSyncEvent.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
task=self.task,
|
|
||||||
provider="claude_cli",
|
|
||||||
status="waiting_approval",
|
|
||||||
payload={},
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
run = CodexRun.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
task=self.task,
|
|
||||||
project=self.project,
|
|
||||||
source_service="web",
|
|
||||||
source_channel="web-chan-1",
|
|
||||||
status="waiting_approval",
|
|
||||||
request_payload={
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": {"task_id": str(self.task.id)},
|
|
||||||
},
|
|
||||||
result_payload={},
|
|
||||||
)
|
|
||||||
CodexPermissionRequest.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
codex_run=run,
|
|
||||||
external_sync_event=waiting_event,
|
|
||||||
approval_key="cl-ak-123",
|
|
||||||
summary="Need approval",
|
|
||||||
requested_permissions={"items": ["write"]},
|
|
||||||
resume_payload={"resume": True},
|
|
||||||
status="pending",
|
|
||||||
)
|
|
||||||
CommandChannelBinding.objects.create(
|
|
||||||
profile=self.profile,
|
|
||||||
direction="ingress",
|
|
||||||
service="web",
|
|
||||||
channel_identifier="approver-chan",
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
trigger = self._msg(
|
|
||||||
"#claude approve cl-ak-123#", source_chat_id="approver-chan"
|
|
||||||
)
|
|
||||||
results = async_to_sync(process_inbound_message)(
|
|
||||||
CommandContext(
|
|
||||||
service="web",
|
|
||||||
channel_identifier="approver-chan",
|
|
||||||
message_id=str(trigger.id),
|
|
||||||
user_id=self.user.id,
|
|
||||||
message_text=str(trigger.text),
|
|
||||||
payload={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(1, len(results))
|
|
||||||
self.assertTrue(results[0].ok)
|
|
||||||
run.refresh_from_db()
|
|
||||||
waiting_event.refresh_from_db()
|
|
||||||
self.assertEqual("approved_waiting_resume", run.status)
|
|
||||||
self.assertEqual("ok", waiting_event.status)
|
|
||||||
self.assertTrue(
|
|
||||||
ExternalSyncEvent.objects.filter(
|
|
||||||
idempotency_key="claude_approval:cl-ak-123:approved",
|
|
||||||
status="pending",
|
|
||||||
).exists()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_deny_command_marks_run_denied(self):
|
|
||||||
waiting_event = ExternalSyncEvent.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
task=self.task,
|
|
||||||
provider="claude_cli",
|
|
||||||
status="waiting_approval",
|
|
||||||
payload={},
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
run = CodexRun.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
task=self.task,
|
|
||||||
project=self.project,
|
|
||||||
source_service="web",
|
|
||||||
source_channel="web-chan-1",
|
|
||||||
status="waiting_approval",
|
|
||||||
request_payload={},
|
|
||||||
result_payload={},
|
|
||||||
)
|
|
||||||
CodexPermissionRequest.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
codex_run=run,
|
|
||||||
external_sync_event=waiting_event,
|
|
||||||
approval_key="cl-deny-1",
|
|
||||||
summary="Need approval",
|
|
||||||
requested_permissions={"items": ["write"]},
|
|
||||||
resume_payload={},
|
|
||||||
status="pending",
|
|
||||||
)
|
|
||||||
CommandChannelBinding.objects.get_or_create(
|
|
||||||
profile=self.profile,
|
|
||||||
direction="ingress",
|
|
||||||
service="web",
|
|
||||||
channel_identifier="approver-chan",
|
|
||||||
defaults={"enabled": True},
|
|
||||||
)
|
|
||||||
trigger = self._msg(".claude deny cl-deny-1", source_chat_id="approver-chan")
|
|
||||||
results = async_to_sync(process_inbound_message)(
|
|
||||||
CommandContext(
|
|
||||||
service="web",
|
|
||||||
channel_identifier="approver-chan",
|
|
||||||
message_id=str(trigger.id),
|
|
||||||
user_id=self.user.id,
|
|
||||||
message_text=str(trigger.text),
|
|
||||||
payload={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(1, len(results))
|
|
||||||
self.assertTrue(results[0].ok)
|
|
||||||
run.refresh_from_db()
|
|
||||||
self.assertEqual("denied", run.status)
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from subprocess import CompletedProcess, TimeoutExpired
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.test import SimpleTestCase
|
|
||||||
|
|
||||||
from core.tasks.providers.codex_cli import CodexCLITaskProvider
|
|
||||||
|
|
||||||
|
|
||||||
class CodexCLITaskProviderTests(SimpleTestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.provider = CodexCLITaskProvider()
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
|
||||||
def test_healthcheck_success(self, run_mock):
|
|
||||||
run_mock.return_value = CompletedProcess(
|
|
||||||
args=["codex", "--version"],
|
|
||||||
returncode=0,
|
|
||||||
stdout="codex 1.2.3\n",
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
result = self.provider.healthcheck({"command": "codex", "timeout_seconds": 5})
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertIn("codex", str(result.payload.get("stdout") or ""))
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
|
||||||
def test_create_task_builds_task_sync_command(self, run_mock):
|
|
||||||
run_mock.return_value = CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=0,
|
|
||||||
stdout='{"external_key":"cx-123"}',
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
result = self.provider.create_task(
|
|
||||||
{
|
|
||||||
"command": "codex",
|
|
||||||
"workspace_root": "/tmp/work",
|
|
||||||
"default_profile": "default",
|
|
||||||
"timeout_seconds": 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"task_id": "t1",
|
|
||||||
"title": "hello",
|
|
||||||
"reference_code": "42",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertEqual("cx-123", result.external_key)
|
|
||||||
args = run_mock.call_args.args[0]
|
|
||||||
self.assertEqual(["codex", "task-sync", "--op", "create"], args[:4])
|
|
||||||
self.assertIn("--workspace", args)
|
|
||||||
self.assertIn("--payload-json", args)
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
|
||||||
def test_timeout_maps_to_failed_result(self, run_mock):
|
|
||||||
run_mock.side_effect = TimeoutExpired(cmd=["codex"], timeout=10)
|
|
||||||
result = self.provider.append_update(
|
|
||||||
{"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"}
|
|
||||||
)
|
|
||||||
self.assertFalse(result.ok)
|
|
||||||
self.assertIn("timeout", result.error)
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
|
||||||
def test_requires_approval_parsed_from_stdout(self, run_mock):
|
|
||||||
run_mock.return_value = CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=0,
|
|
||||||
stdout='{"status":"requires_approval","approval_key":"ak-1","permission_request":{"requested_permissions":["write"]}}',
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
result = self.provider.append_update({"command": "codex"}, {"task_id": "t1"})
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
|
|
||||||
self.assertEqual(
|
|
||||||
"requires_approval", (result.payload or {}).get("parsed_status")
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
|
||||||
def test_retries_with_positional_op_when_flag_unsupported(self, run_mock):
|
|
||||||
run_mock.side_effect = [
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=2,
|
|
||||||
stdout="",
|
|
||||||
stderr="error: unexpected argument '--op' found",
|
|
||||||
),
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=0,
|
|
||||||
stdout='{"status":"ok","external_key":"cx-42"}',
|
|
||||||
stderr="",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
result = self.provider.create_task({"command": "codex"}, {"task_id": "t1"})
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertEqual("cx-42", result.external_key)
|
|
||||||
self.assertEqual(2, run_mock.call_count)
|
|
||||||
first = run_mock.call_args_list[0].args[0]
|
|
||||||
second = run_mock.call_args_list[1].args[0]
|
|
||||||
self.assertIn("--op", first)
|
|
||||||
self.assertNotIn("--op", second)
|
|
||||||
self.assertEqual(["codex", "task-sync", "create"], second[:3])
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
|
||||||
def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(
|
|
||||||
self, run_mock
|
|
||||||
):
|
|
||||||
run_mock.side_effect = [
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=2,
|
|
||||||
stdout="",
|
|
||||||
stderr="error: unexpected argument '--op' found",
|
|
||||||
),
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=2,
|
|
||||||
stdout="",
|
|
||||||
stderr="error: unrecognized subcommand 'create'\nUsage: codex [OPTIONS] [PROMPT]",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
result = self.provider.create_task(
|
|
||||||
{"command": "codex"},
|
|
||||||
{
|
|
||||||
"task_id": "t1",
|
|
||||||
"trigger_message_id": "m1",
|
|
||||||
"mode": "default",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
|
|
||||||
self.assertEqual(
|
|
||||||
"requires_approval", str((result.payload or {}).get("status") or "")
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
"builtin_task_sync_stub",
|
|
||||||
str((result.payload or {}).get("fallback_mode") or ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
|
||||||
def test_builtin_stub_approval_response_returns_ok(self, run_mock):
|
|
||||||
run_mock.side_effect = [
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=2,
|
|
||||||
stdout="",
|
|
||||||
stderr="error: unexpected argument '--op' found",
|
|
||||||
),
|
|
||||||
CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=2,
|
|
||||||
stdout="",
|
|
||||||
stderr="error: unexpected argument 'append_update' found\nUsage: codex [OPTIONS] [PROMPT]",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
result = self.provider.append_update(
|
|
||||||
{"command": "codex"},
|
|
||||||
{
|
|
||||||
"task_id": "t1",
|
|
||||||
"mode": "approval_response",
|
|
||||||
"approval_key": "abc123",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result.ok)
|
|
||||||
self.assertFalse(bool((result.payload or {}).get("requires_approval")))
|
|
||||||
self.assertEqual("ok", str((result.payload or {}).get("status") or ""))
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from core.commands.base import CommandContext
|
|
||||||
from core.commands.engine import process_inbound_message
|
|
||||||
from core.commands.handlers.codex import parse_codex_command
|
|
||||||
from core.models import (
|
|
||||||
ChatSession,
|
|
||||||
CodexPermissionRequest,
|
|
||||||
CodexRun,
|
|
||||||
CommandChannelBinding,
|
|
||||||
CommandProfile,
|
|
||||||
DerivedTask,
|
|
||||||
ExternalSyncEvent,
|
|
||||||
Message,
|
|
||||||
Person,
|
|
||||||
PersonIdentifier,
|
|
||||||
TaskProject,
|
|
||||||
TaskProviderConfig,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CodexCommandParserTests(TestCase):
|
|
||||||
def test_parse_variants(self):
|
|
||||||
self.assertEqual("default", parse_codex_command("#codex# run this").command)
|
|
||||||
self.assertEqual("plan", parse_codex_command("#codex plan# run this").command)
|
|
||||||
self.assertEqual("status", parse_codex_command("#codex status#").command)
|
|
||||||
parsed = parse_codex_command("#codex approve abc123#")
|
|
||||||
self.assertEqual("approve", parsed.command)
|
|
||||||
self.assertEqual("abc123", parsed.approval_key)
|
|
||||||
self.assertEqual("default", parse_codex_command(".codex run this").command)
|
|
||||||
self.assertEqual("plan", parse_codex_command(".CODEX plan run this").command)
|
|
||||||
self.assertEqual("status", parse_codex_command(".codex status").command)
|
|
||||||
parsed_dot = parse_codex_command(".codex approve abc123")
|
|
||||||
self.assertEqual("approve", parsed_dot.command)
|
|
||||||
self.assertEqual("abc123", parsed_dot.approval_key)
|
|
||||||
|
|
||||||
|
|
||||||
class CodexCommandExecutionTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(
|
|
||||||
"codex-cmd-user", "codex-cmd@example.com", "x"
|
|
||||||
)
|
|
||||||
self.person = Person.objects.create(user=self.user, name="Codex Cmd")
|
|
||||||
self.identifier = PersonIdentifier.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
person=self.person,
|
|
||||||
service="web",
|
|
||||||
identifier="web-chan-1",
|
|
||||||
)
|
|
||||||
self.session = ChatSession.objects.create(
|
|
||||||
user=self.user, identifier=self.identifier
|
|
||||||
)
|
|
||||||
self.project = TaskProject.objects.create(user=self.user, name="Project A")
|
|
||||||
self.task = DerivedTask.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
project=self.project,
|
|
||||||
epic=None,
|
|
||||||
title="Task A",
|
|
||||||
source_service="web",
|
|
||||||
source_channel="web-chan-1",
|
|
||||||
reference_code="1",
|
|
||||||
status_snapshot="open",
|
|
||||||
)
|
|
||||||
self.profile = CommandProfile.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
slug="codex",
|
|
||||||
name="Codex",
|
|
||||||
enabled=True,
|
|
||||||
trigger_token="#codex#",
|
|
||||||
reply_required=False,
|
|
||||||
exact_match_only=False,
|
|
||||||
)
|
|
||||||
CommandChannelBinding.objects.create(
|
|
||||||
profile=self.profile,
|
|
||||||
direction="ingress",
|
|
||||||
service="web",
|
|
||||||
channel_identifier="web-chan-1",
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
TaskProviderConfig.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
provider="codex_cli",
|
|
||||||
enabled=True,
|
|
||||||
settings={
|
|
||||||
"command": "codex",
|
|
||||||
"workspace_root": "",
|
|
||||||
"default_profile": "",
|
|
||||||
"timeout_seconds": 60,
|
|
||||||
"chat_link_mode": "task-sync",
|
|
||||||
"instance_label": "default",
|
|
||||||
"approver_mode": "channel",
|
|
||||||
"approver_service": "web",
|
|
||||||
"approver_identifier": "approver-chan",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _msg(self, text: str, *, source_chat_id: str = "web-chan-1", reply_to=None):
|
|
||||||
return Message.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
session=self.session,
|
|
||||||
sender_uuid="",
|
|
||||||
text=text,
|
|
||||||
ts=1000 + Message.objects.filter(user=self.user).count(),
|
|
||||||
source_service="web",
|
|
||||||
source_chat_id=source_chat_id,
|
|
||||||
reply_to=reply_to,
|
|
||||||
message_meta={},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_default_submission_creates_run_and_event(self):
|
|
||||||
trigger = self._msg("#codex# please update #1")
|
|
||||||
results = async_to_sync(process_inbound_message)(
|
|
||||||
CommandContext(
|
|
||||||
service="web",
|
|
||||||
channel_identifier="web-chan-1",
|
|
||||||
message_id=str(trigger.id),
|
|
||||||
user_id=self.user.id,
|
|
||||||
message_text=str(trigger.text),
|
|
||||||
payload={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(1, len(results))
|
|
||||||
self.assertTrue(results[0].ok)
|
|
||||||
run = CodexRun.objects.order_by("-created_at").first()
|
|
||||||
self.assertIsNotNone(run)
|
|
||||||
self.assertEqual("waiting_approval", run.status)
|
|
||||||
event = ExternalSyncEvent.objects.order_by("-created_at").first()
|
|
||||||
self.assertEqual("waiting_approval", event.status)
|
|
||||||
self.assertEqual(
|
|
||||||
"default",
|
|
||||||
str((event.payload or {}).get("provider_payload", {}).get("mode") or ""),
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
CodexPermissionRequest.objects.filter(
|
|
||||||
user=self.user,
|
|
||||||
codex_run=run,
|
|
||||||
status="pending",
|
|
||||||
).exists()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_plan_requires_reply_anchor(self):
|
|
||||||
trigger = self._msg("#codex plan# #1")
|
|
||||||
results = async_to_sync(process_inbound_message)(
|
|
||||||
CommandContext(
|
|
||||||
service="web",
|
|
||||||
channel_identifier="web-chan-1",
|
|
||||||
message_id=str(trigger.id),
|
|
||||||
user_id=self.user.id,
|
|
||||||
message_text=str(trigger.text),
|
|
||||||
payload={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(1, len(results))
|
|
||||||
self.assertFalse(results[0].ok)
|
|
||||||
self.assertEqual("reply_required_for_codex_plan", results[0].error)
|
|
||||||
|
|
||||||
def test_approve_command_queues_resume_event(self):
|
|
||||||
waiting_event = ExternalSyncEvent.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
task=self.task,
|
|
||||||
provider="codex_cli",
|
|
||||||
status="waiting_approval",
|
|
||||||
payload={},
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
run = CodexRun.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
task=self.task,
|
|
||||||
project=self.project,
|
|
||||||
source_service="web",
|
|
||||||
source_channel="web-chan-1",
|
|
||||||
status="waiting_approval",
|
|
||||||
request_payload={
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": {"task_id": str(self.task.id)},
|
|
||||||
},
|
|
||||||
result_payload={},
|
|
||||||
)
|
|
||||||
req = CodexPermissionRequest.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
codex_run=run,
|
|
||||||
external_sync_event=waiting_event,
|
|
||||||
approval_key="ak-123",
|
|
||||||
summary="Need approval",
|
|
||||||
requested_permissions={"items": ["write"]},
|
|
||||||
resume_payload={"resume": True},
|
|
||||||
status="pending",
|
|
||||||
)
|
|
||||||
CommandChannelBinding.objects.create(
|
|
||||||
profile=self.profile,
|
|
||||||
direction="ingress",
|
|
||||||
service="web",
|
|
||||||
channel_identifier="approver-chan",
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
trigger = self._msg("#codex approve ak-123#", source_chat_id="approver-chan")
|
|
||||||
results = async_to_sync(process_inbound_message)(
|
|
||||||
CommandContext(
|
|
||||||
service="web",
|
|
||||||
channel_identifier="approver-chan",
|
|
||||||
message_id=str(trigger.id),
|
|
||||||
user_id=self.user.id,
|
|
||||||
message_text=str(trigger.text),
|
|
||||||
payload={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(1, len(results))
|
|
||||||
self.assertTrue(results[0].ok)
|
|
||||||
req.refresh_from_db()
|
|
||||||
run.refresh_from_db()
|
|
||||||
waiting_event.refresh_from_db()
|
|
||||||
self.assertEqual("approved", req.status)
|
|
||||||
self.assertEqual("approved_waiting_resume", run.status)
|
|
||||||
self.assertEqual("ok", waiting_event.status)
|
|
||||||
self.assertTrue(
|
|
||||||
ExternalSyncEvent.objects.filter(
|
|
||||||
idempotency_key="codex_approval:ak-123:approved", status="pending"
|
|
||||||
).exists()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_approve_pre_submit_request_queues_original_action(self):
|
|
||||||
waiting_event = ExternalSyncEvent.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
task=self.task,
|
|
||||||
provider="codex_cli",
|
|
||||||
status="waiting_approval",
|
|
||||||
payload={},
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
run = CodexRun.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
task=self.task,
|
|
||||||
project=self.project,
|
|
||||||
source_service="web",
|
|
||||||
source_channel="web-chan-1",
|
|
||||||
status="waiting_approval",
|
|
||||||
request_payload={
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": {"task_id": str(self.task.id)},
|
|
||||||
},
|
|
||||||
result_payload={},
|
|
||||||
)
|
|
||||||
CodexPermissionRequest.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
codex_run=run,
|
|
||||||
external_sync_event=waiting_event,
|
|
||||||
approval_key="pre-ak-1",
|
|
||||||
summary="pre submit",
|
|
||||||
requested_permissions={"type": "pre_submit"},
|
|
||||||
resume_payload={
|
|
||||||
"gate_type": "pre_submit",
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": {"task_id": str(self.task.id), "mode": "default"},
|
|
||||||
"idempotency_key": "codex_cmd:resume:1",
|
|
||||||
},
|
|
||||||
status="pending",
|
|
||||||
)
|
|
||||||
CommandChannelBinding.objects.get_or_create(
|
|
||||||
profile=self.profile,
|
|
||||||
direction="ingress",
|
|
||||||
service="web",
|
|
||||||
channel_identifier="approver-chan",
|
|
||||||
defaults={"enabled": True},
|
|
||||||
)
|
|
||||||
trigger = self._msg(".codex approve pre-ak-1", source_chat_id="approver-chan")
|
|
||||||
results = async_to_sync(process_inbound_message)(
|
|
||||||
CommandContext(
|
|
||||||
service="web",
|
|
||||||
channel_identifier="approver-chan",
|
|
||||||
message_id=str(trigger.id),
|
|
||||||
user_id=self.user.id,
|
|
||||||
message_text=str(trigger.text),
|
|
||||||
payload={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(1, len(results))
|
|
||||||
self.assertTrue(results[0].ok)
|
|
||||||
resume = ExternalSyncEvent.objects.filter(
|
|
||||||
idempotency_key="codex_cmd:resume:1"
|
|
||||||
).first()
|
|
||||||
self.assertIsNotNone(resume)
|
|
||||||
self.assertEqual("pending", resume.status)
|
|
||||||
self.assertEqual(
|
|
||||||
"append_update", str((resume.payload or {}).get("action") or "")
|
|
||||||
)
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from core.management.commands.codex_worker import Command as CodexWorkerCommand
|
|
||||||
from core.models import (
|
|
||||||
CodexPermissionRequest,
|
|
||||||
CodexRun,
|
|
||||||
ExternalSyncEvent,
|
|
||||||
TaskProject,
|
|
||||||
TaskProviderConfig,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from core.tasks.providers.base import ProviderResult
|
|
||||||
|
|
||||||
|
|
||||||
class CodexWorkerPhase1Tests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(
|
|
||||||
"codex-worker-user", "codex-worker@example.com", "x"
|
|
||||||
)
|
|
||||||
self.project = TaskProject.objects.create(user=self.user, name="Worker Project")
|
|
||||||
self.cfg = TaskProviderConfig.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
provider="codex_cli",
|
|
||||||
enabled=True,
|
|
||||||
settings={
|
|
||||||
"command": "codex",
|
|
||||||
"workspace_root": "",
|
|
||||||
"default_profile": "",
|
|
||||||
"timeout_seconds": 60,
|
|
||||||
"chat_link_mode": "task-sync",
|
|
||||||
"instance_label": "default",
|
|
||||||
"approver_mode": "channel",
|
|
||||||
"approver_service": "",
|
|
||||||
"approver_identifier": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("core.management.commands.codex_worker.get_provider")
|
|
||||||
def test_pending_to_ok_updates_run(self, get_provider_mock):
|
|
||||||
run = CodexRun.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
project=self.project,
|
|
||||||
source_service="web",
|
|
||||||
source_channel="web-chan-1",
|
|
||||||
status="queued",
|
|
||||||
request_payload={},
|
|
||||||
result_payload={},
|
|
||||||
)
|
|
||||||
event = ExternalSyncEvent.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
provider="codex_cli",
|
|
||||||
status="pending",
|
|
||||||
payload={
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": {
|
|
||||||
"codex_run_id": str(run.id),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
class _Provider:
|
|
||||||
run_in_worker = True
|
|
||||||
|
|
||||||
def append_update(self, config, payload):
|
|
||||||
return ProviderResult(
|
|
||||||
ok=True, payload={"status": "ok", "summary": "done"}
|
|
||||||
)
|
|
||||||
|
|
||||||
create_task = mark_complete = link_task = append_update
|
|
||||||
|
|
||||||
get_provider_mock.return_value = _Provider()
|
|
||||||
CodexWorkerCommand()._run_event(event)
|
|
||||||
|
|
||||||
event.refresh_from_db()
|
|
||||||
run.refresh_from_db()
|
|
||||||
self.assertEqual("ok", event.status)
|
|
||||||
self.assertEqual("ok", run.status)
|
|
||||||
self.assertEqual("done", str(run.result_payload.get("summary") or ""))
|
|
||||||
|
|
||||||
@patch("core.management.commands.codex_worker.get_provider")
|
|
||||||
def test_requires_approval_moves_to_waiting_and_creates_permission_request(
|
|
||||||
self, get_provider_mock
|
|
||||||
):
|
|
||||||
run = CodexRun.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
project=self.project,
|
|
||||||
source_service="web",
|
|
||||||
source_channel="web-chan-1",
|
|
||||||
status="queued",
|
|
||||||
request_payload={},
|
|
||||||
result_payload={},
|
|
||||||
)
|
|
||||||
event = ExternalSyncEvent.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
provider="codex_cli",
|
|
||||||
status="pending",
|
|
||||||
payload={
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": {
|
|
||||||
"codex_run_id": str(run.id),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
class _Provider:
|
|
||||||
run_in_worker = True
|
|
||||||
|
|
||||||
def append_update(self, config, payload):
|
|
||||||
return ProviderResult(
|
|
||||||
ok=True,
|
|
||||||
payload={
|
|
||||||
"status": "requires_approval",
|
|
||||||
"requires_approval": True,
|
|
||||||
"approval_key": "ak-worker-1",
|
|
||||||
"summary": "needs permissions",
|
|
||||||
"permission_request": {"requested_permissions": ["write"]},
|
|
||||||
"resume_payload": {"resume": True},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
create_task = mark_complete = link_task = append_update
|
|
||||||
|
|
||||||
get_provider_mock.return_value = _Provider()
|
|
||||||
CodexWorkerCommand()._run_event(event)
|
|
||||||
|
|
||||||
event.refresh_from_db()
|
|
||||||
run.refresh_from_db()
|
|
||||||
self.assertEqual("waiting_approval", event.status)
|
|
||||||
self.assertEqual("waiting_approval", run.status)
|
|
||||||
request = CodexPermissionRequest.objects.get(approval_key="ak-worker-1")
|
|
||||||
self.assertEqual("pending", request.status)
|
|
||||||
self.assertEqual(str(run.id), str(request.codex_run_id))
|
|
||||||
|
|
||||||
@patch("core.management.commands.codex_worker.get_provider")
|
|
||||||
def test_approval_response_marks_original_waiting_event_ok(self, get_provider_mock):
|
|
||||||
waiting_event = ExternalSyncEvent.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
provider="codex_cli",
|
|
||||||
status="waiting_approval",
|
|
||||||
payload={
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": {"mode": "default"},
|
|
||||||
},
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
run = CodexRun.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
project=self.project,
|
|
||||||
source_service="web",
|
|
||||||
source_channel="web-chan-1",
|
|
||||||
status="approved_waiting_resume",
|
|
||||||
request_payload={},
|
|
||||||
result_payload={},
|
|
||||||
)
|
|
||||||
CodexPermissionRequest.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
codex_run=run,
|
|
||||||
external_sync_event=waiting_event,
|
|
||||||
approval_key="ak-worker-ok",
|
|
||||||
summary="needs permissions",
|
|
||||||
requested_permissions={"items": ["write"]},
|
|
||||||
resume_payload={"resume": True},
|
|
||||||
status="approved",
|
|
||||||
)
|
|
||||||
resume_event = ExternalSyncEvent.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
provider="codex_cli",
|
|
||||||
status="pending",
|
|
||||||
payload={
|
|
||||||
"action": "append_update",
|
|
||||||
"provider_payload": {
|
|
||||||
"mode": "approval_response",
|
|
||||||
"approval_key": "ak-worker-ok",
|
|
||||||
"codex_run_id": str(run.id),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
error="",
|
|
||||||
)
|
|
||||||
|
|
||||||
class _Provider:
|
|
||||||
run_in_worker = True
|
|
||||||
|
|
||||||
def append_update(self, config, payload):
|
|
||||||
return ProviderResult(
|
|
||||||
ok=True, payload={"status": "ok", "summary": "resumed"}
|
|
||||||
)
|
|
||||||
|
|
||||||
create_task = mark_complete = link_task = append_update
|
|
||||||
|
|
||||||
get_provider_mock.return_value = _Provider()
|
|
||||||
CodexWorkerCommand()._run_event(resume_event)
|
|
||||||
|
|
||||||
waiting_event.refresh_from_db()
|
|
||||||
resume_event.refresh_from_db()
|
|
||||||
self.assertEqual("ok", resume_event.status)
|
|
||||||
self.assertEqual("ok", waiting_event.status)
|
|
||||||
@@ -32,8 +32,7 @@ class CommandRoutingVariantUITests(TestCase):
|
|||||||
self.assertContains(response, "Variant Policies")
|
self.assertContains(response, "Variant Policies")
|
||||||
self.assertContains(response, "bp set range")
|
self.assertContains(response, "bp set range")
|
||||||
self.assertContains(response, "Send status to egress")
|
self.assertContains(response, "Send status to egress")
|
||||||
self.assertContains(response, "Codex (codex)")
|
self.assertContains(response, "Business Plan (bp)")
|
||||||
self.assertContains(response, "Claude (claude)")
|
|
||||||
|
|
||||||
def test_variant_policy_update_persists(self):
|
def test_variant_policy_update_persists(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from core.models import User
|
from core.models import ChatSession, Message, Person, PersonIdentifier, User
|
||||||
|
|
||||||
|
|
||||||
class ComposeSendCapabilityTests(TestCase):
|
class ComposeSendCapabilityTests(TestCase):
|
||||||
@@ -78,6 +78,9 @@ class ComposeSendCapabilityTests(TestCase):
|
|||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
content = response.content.decode("utf-8")
|
content = response.content.decode("utf-8")
|
||||||
self.assertIn("compose-panel.css", content)
|
self.assertIn("compose-panel.css", content)
|
||||||
|
self.assertIn("compose-panel-core.js", content)
|
||||||
|
self.assertIn("compose-panel-thread.js", content)
|
||||||
|
self.assertIn("compose-panel-send.js", content)
|
||||||
self.assertIn("compose-panel.js", content)
|
self.assertIn("compose-panel.js", content)
|
||||||
self.assertNotIn("const initialTyping = JSON.parse(", content)
|
self.assertNotIn("const initialTyping = JSON.parse(", content)
|
||||||
self.assertNotIn("data-drafts-url=", content)
|
self.assertNotIn("data-drafts-url=", content)
|
||||||
@@ -89,6 +92,33 @@ class ComposeSendCapabilityTests(TestCase):
|
|||||||
self.assertNotIn("compose-ticks", content)
|
self.assertNotIn("compose-ticks", content)
|
||||||
self.assertNotIn("compose-receipt-modal", content)
|
self.assertNotIn("compose-receipt-modal", content)
|
||||||
|
|
||||||
|
def test_compose_widget_declares_compose_assets_on_widget_shell(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("compose_widget"),
|
||||||
|
{
|
||||||
|
"service": "signal",
|
||||||
|
"identifier": "+15551230000",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
content = response.content.decode("utf-8")
|
||||||
|
self.assertIn("data-gia-style-hrefs=", content)
|
||||||
|
self.assertIn("/static/css/compose-panel.css", content)
|
||||||
|
self.assertIn("data-gia-script-srcs=", content)
|
||||||
|
self.assertIn("/static/js/compose-panel-core.js", content)
|
||||||
|
self.assertIn("/static/js/compose-panel-thread.js", content)
|
||||||
|
self.assertIn("/static/js/compose-panel-send.js", content)
|
||||||
|
self.assertIn("/static/js/compose-panel.js", content)
|
||||||
|
self.assertNotIn("<script defer src=\"/static/js/compose-panel.js\">", content)
|
||||||
|
self.assertNotIn("<link rel=\"stylesheet\" href=\"/static/css/compose-panel.css\">", content)
|
||||||
|
|
||||||
|
def test_compose_contacts_dropdown_includes_workspace_link(self):
|
||||||
|
response = self.client.get(reverse("compose_contacts_dropdown"))
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertContains(response, reverse("compose_workspace"))
|
||||||
|
|
||||||
@patch("core.views.compose._recent_manual_contacts")
|
@patch("core.views.compose._recent_manual_contacts")
|
||||||
def test_compose_contact_options_use_compact_service_map(self, mocked_recent_contacts):
|
def test_compose_contact_options_use_compact_service_map(self, mocked_recent_contacts):
|
||||||
mocked_recent_contacts.return_value = [
|
mocked_recent_contacts.return_value = [
|
||||||
@@ -129,6 +159,80 @@ class ComposeSendCapabilityTests(TestCase):
|
|||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
self.assertIn("messages", payload)
|
self.assertIn("messages", payload)
|
||||||
|
self.assertIn("messages_html", payload)
|
||||||
self.assertIn("typing", payload)
|
self.assertIn("typing", payload)
|
||||||
self.assertNotIn("availability_slices", payload)
|
self.assertNotIn("availability_slices", payload)
|
||||||
self.assertNotIn("availability_summary", payload)
|
self.assertNotIn("availability_summary", payload)
|
||||||
|
|
||||||
|
def test_compose_thread_payload_includes_rendered_message_rows(self):
|
||||||
|
person = Person.objects.create(user=self.user, name="Rendered Contact")
|
||||||
|
identifier = PersonIdentifier.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
person=person,
|
||||||
|
service="signal",
|
||||||
|
identifier="+15551230000",
|
||||||
|
)
|
||||||
|
session = ChatSession.objects.create(user=self.user, identifier=identifier)
|
||||||
|
Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=session,
|
||||||
|
sender_uuid="contact",
|
||||||
|
text="Rendered thread row",
|
||||||
|
ts=1710000000000,
|
||||||
|
custom_author="CONTACT",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("compose_thread"),
|
||||||
|
{
|
||||||
|
"service": "signal",
|
||||||
|
"identifier": "+15551230000",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertIsInstance(payload.get("messages_html"), str)
|
||||||
|
self.assertIn("compose-row", str(payload.get("messages_html") or ""))
|
||||||
|
self.assertIn("Rendered thread row", str(payload.get("messages_html") or ""))
|
||||||
|
|
||||||
|
def test_compose_thread_payload_renders_reply_link_text_server_side(self):
|
||||||
|
person = Person.objects.create(user=self.user, name="Reply Contact")
|
||||||
|
identifier = PersonIdentifier.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
person=person,
|
||||||
|
service="signal",
|
||||||
|
identifier="+15551239999",
|
||||||
|
)
|
||||||
|
session = ChatSession.objects.create(user=self.user, identifier=identifier)
|
||||||
|
anchor = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=session,
|
||||||
|
sender_uuid="contact",
|
||||||
|
text="Anchor message for reply preview",
|
||||||
|
ts=1710000000000,
|
||||||
|
custom_author="CONTACT",
|
||||||
|
)
|
||||||
|
Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=session,
|
||||||
|
sender_uuid="self",
|
||||||
|
text="Reply message",
|
||||||
|
ts=1710000001000,
|
||||||
|
custom_author="USER",
|
||||||
|
reply_to=anchor,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("compose_thread"),
|
||||||
|
{
|
||||||
|
"service": "signal",
|
||||||
|
"identifier": "+15551239999",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
payload = response.json()
|
||||||
|
html = str(payload.get("messages_html") or "")
|
||||||
|
self.assertIn("Reply to: Anchor message for reply preview", html)
|
||||||
|
self.assertNotIn("data-reply-preview=", html)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user