Reimplement compose and add tiling windows

This commit is contained in:
2026-03-12 22:03:30 +00:00
parent 79766d279d
commit 6ceff63b71
126 changed files with 5111 additions and 10796 deletions

View File

@@ -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(),

View File

@@ -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

View File

@@ -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",

View File

@@ -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`

View 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.

View File

@@ -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:

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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 {}

View File

@@ -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",

View File

@@ -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

View File

@@ -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)."
)

View File

@@ -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,

View File

@@ -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),
} }

View File

@@ -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
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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}

File diff suppressed because one or more lines are too long

View File

@@ -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%;
} }

View File

@@ -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

View File

@@ -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 Djangos 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 Djangos onload function since browser wont
window.onload();
}
});
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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);
});

View 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,
};
})();

View 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,
};
})();

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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() + "..."

View File

@@ -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"])

View File

@@ -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() + "..."

View File

@@ -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(),
} }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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) {

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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";
} }
}; };

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 {

View File

@@ -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 %}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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">&rarr;</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>

View 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 %}

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }}">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -1 +1,9 @@
{% extends 'base.html' %} {% extends "base.html" %}
{% block content_wrapper %}
<section class="section">
<div class="container">
{% block content %}{% endblock %}
</div>
</section>
{% endblock %}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 ""))

View File

@@ -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 "")
)

View File

@@ -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)

View File

@@ -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(

View File

@@ -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