diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg
index 62ded67..9a71bfb 100644
--- a/internal/service/static/icons.svg
+++ b/internal/service/static/icons.svg
@@ -119,4 +119,9 @@
+
+
+
+
+
diff --git a/internal/service/static/main.css b/internal/service/static/main.css
index 0a1a37c..680d635 100644
--- a/internal/service/static/main.css
+++ b/internal/service/static/main.css
@@ -273,6 +273,10 @@ body {
height: var(--action-icon-size);
}
+.compose__action-btn--location.active {
+ color: var(--color-green);
+}
+
.compose__action-btn--record.recording {
color: white;
background: var(--color-recording);
@@ -330,6 +334,12 @@ body {
border-top: 1px dotted var(--color-border-light);
}
+.compose__actions-left {
+ display: flex;
+ align-items: center;
+ gap: 0.125rem;
+}
+
.compose__actions-right {
display: flex;
align-items: center;
diff --git a/internal/service/static/main.js b/internal/service/static/main.js
index 3dc1a87..4f3d998 100644
--- a/internal/service/static/main.js
+++ b/internal/service/static/main.js
@@ -61,6 +61,7 @@ class Odidere {
this.isResetPending = false;
this.isScrollPending = false;
this.isStopPending = false;
+ this.locationData = null;
this.mediaRecorder = null;
this.wakeLock = null;
this.pendingDeleteId = null;
@@ -95,6 +96,7 @@ class Odidere {
this.$chat = document.getElementById('chat');
this.$chatLoading = document.getElementById('chat-loading');
this.$fileInput = document.getElementById('file-input');
+ this.$location = document.getElementById('location');
this.$model = document.getElementById('model');
this.$ptt = document.getElementById('ptt');
this.$reset = document.getElementById('reset');
@@ -170,6 +172,7 @@ class Odidere {
el.remove();
});
this.#clearAttachments();
+ this.#clearLocation();
this.$textInput.value = '';
this.$textInput.style.height = 'auto';
this.totalTokens = 0;
@@ -231,6 +234,11 @@ class Odidere {
this.document.addEventListener('click', (e) => this.#handleScrollCancel(e));
this.document.addEventListener('click', (e) => this.#handleDeleteCancel(e));
+ // Location toggle.
+ this.$location.addEventListener('click', () =>
+ this.#handleLocationToggle(),
+ );
+
// File attachment.
this.$attach.addEventListener('click', () => this.$fileInput.click());
this.$fileInput.addEventListener('change', (e) =>
@@ -656,6 +664,38 @@ class Odidere {
this.#submitText();
};
+ /**
+ * #handleLocationToggle toggles GPS location inclusion.
+ * When enabled, requests the user's current position via the Geolocation API.
+ * If denied or unavailable, silently leaves the button off.
+ */
+ #handleLocationToggle = () => {
+ if (this.locationData) {
+ // Turn off.
+ this.locationData = null;
+ this.$location.classList.remove(
+ 'compose__action-btn--location',
+ 'active',
+ );
+ this.#updateSendButtonState();
+ return;
+ }
+
+ if (!navigator.geolocation) {
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ this.locationData = position;
+ this.$location.classList.add('compose__action-btn--location', 'active');
+ this.#updateSendButtonState();
+ },
+ // On error, leave the button off; user must retry manually.
+ () => {},
+ );
+ };
+
/**
* #handleTextareaInput adjusts textarea height to fit content.
*/
@@ -1469,7 +1509,8 @@ class Odidere {
this.currentController = new AbortController();
try {
- const hasNewInput = text || this.attachments.length > 0;
+ const hasNewInput =
+ text || this.attachments.length > 0 || this.locationData;
// Build messages array from active path + current user message.
const activePath = this.getActivePath();
@@ -1500,8 +1541,9 @@ class Odidere {
payload.system_message = systemMessage;
}
- // Clear attachments after building payload (before async operations).
+ // Clear attachments and location after building payload (before async operations).
this.#clearAttachments();
+ this.#clearLocation();
// For text-only requests with new input, add to history and render
// immediately. Skip when continuing conversation (no new user message).
@@ -1789,6 +1831,12 @@ class Odidere {
textParts.push(text);
}
+ // Include GPS location if enabled (appended at end).
+ if (this.locationData) {
+ const locationText = JSON.stringify(this.locationData, null, 2);
+ textParts.push(locationText);
+ }
+
const combinedText = textParts.join('\n\n');
// If no images, return simple string content.
@@ -1825,8 +1873,9 @@ class Odidere {
#updateSendButtonState() {
const hasText = this.$textInput.value.trim().length > 0;
const hasAttachments = this.attachments.length > 0;
+ const hasLocation = this.locationData !== null;
const hasHistory = this.messagesMap.size > 0;
- const enabled = hasText || hasAttachments || hasHistory;
+ const enabled = hasText || hasAttachments || hasLocation || hasHistory;
this.$send.disabled = !enabled;
this.$send.setAttribute(
'aria-label',
@@ -2705,6 +2754,14 @@ class Odidere {
this.#updateSendButtonState();
}
+ /**
+ * #clearLocation clears location data and resets the button state.
+ */
+ #clearLocation() {
+ this.locationData = null;
+ this.$location.classList.remove('compose__action-btn--location', 'active');
+ }
+
/**
* #clearAttachments removes all attachments.
*/
diff --git a/internal/service/templates/static/footer/compose.gohtml b/internal/service/templates/static/footer/compose.gohtml
index 2e6a19e..363d1a6 100644
--- a/internal/service/templates/static/footer/compose.gohtml
+++ b/internal/service/templates/static/footer/compose.gohtml
@@ -9,14 +9,25 @@
>